finitemonkey 0.1.0-next.1 → 0.1.0-next.2

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.
@@ -14,30 +14,121 @@ var isEntryPoint = (moduleUrl, argv1, realpath, platform) => {
14
14
  };
15
15
 
16
16
  // src/core/node-runtime.ts
17
- import { spawnSync } from "node:child_process";
17
+ import { spawnSync as spawnSync2 } from "node:child_process";
18
18
  import { readFileSync, realpathSync } from "node:fs";
19
19
 
20
+ // src/core/node-fs.ts
21
+ import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeSync } from "node:fs";
22
+ import { dirname, join, relative } from "node:path";
23
+
20
24
  // src/core/parsed.ts
21
25
  var accept = (value) => ({ ok: true, value });
22
26
  var reject = (error) => ({ ok: false, error });
23
27
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
24
- var parseJson = (text) => {
28
+ var parseJson = (text2) => {
25
29
  try {
26
- return accept(JSON.parse(text));
30
+ return accept(JSON.parse(text2));
27
31
  } catch (error) {
28
32
  return reject(error instanceof Error ? error.message : String(error));
29
33
  }
30
34
  };
31
- var parseJsonObject = (text) => {
32
- const parsed = parseJson(text);
35
+ var parseJsonObject = (text2) => {
36
+ const parsed = parseJson(text2);
33
37
  if (!parsed.ok) return parsed;
34
38
  return isRecord(parsed.value) ? accept(parsed.value) : reject("expected a JSON object");
35
39
  };
36
- var optionalString = (record, key) => {
37
- const value = record[key];
40
+ var optionalString = (record2, key) => {
41
+ const value = record2[key];
38
42
  return typeof value === "string" ? value : void 0;
39
43
  };
40
44
 
45
+ // src/core/node-fs.ts
46
+ var SKIPPED_DIRS = /* @__PURE__ */ new Set([".git", "node_modules"]);
47
+ var messageOf = (error) => error instanceof Error ? error.message : String(error);
48
+ var nodeWriteText = (path, text2) => {
49
+ const temp = `${path}.${String(process.pid)}.tmp`;
50
+ try {
51
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
52
+ const fd = openSync(temp, "w", 384);
53
+ try {
54
+ writeSync(fd, text2);
55
+ fsyncSync(fd);
56
+ } finally {
57
+ closeSync(fd);
58
+ }
59
+ renameSync(temp, path);
60
+ return accept(true);
61
+ } catch (error) {
62
+ rmSync(temp, { force: true });
63
+ return reject(messageOf(error));
64
+ }
65
+ };
66
+ var nodeListDir = (path) => {
67
+ try {
68
+ return accept(readdirSync(path).sort());
69
+ } catch (error) {
70
+ return reject(messageOf(error));
71
+ }
72
+ };
73
+ var walkInto = (root, dir, out) => {
74
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
75
+ const full = join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ if (!SKIPPED_DIRS.has(entry.name)) walkInto(root, full, out);
78
+ } else if (entry.isFile()) {
79
+ out.push(relative(root, full).split("\\").join("/"));
80
+ }
81
+ }
82
+ };
83
+ var nodeWalkFiles = (root) => {
84
+ try {
85
+ const out = [];
86
+ walkInto(root, root, out);
87
+ return accept(out.sort());
88
+ } catch (error) {
89
+ return reject(messageOf(error));
90
+ }
91
+ };
92
+
93
+ // src/core/node-git.ts
94
+ import { spawn, spawnSync } from "node:child_process";
95
+ import { createInterface } from "node:readline";
96
+ var MAX_BUFFER = 256 * 1024 * 1024;
97
+ var GIT_ENV = { ...process.env, GIT_PAGER: "cat", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" };
98
+ var notFound = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
99
+ var nodeGit = (args, cwd) => {
100
+ const result = spawnSync("git", [...args], {
101
+ cwd,
102
+ encoding: "utf8",
103
+ maxBuffer: MAX_BUFFER,
104
+ windowsHide: true,
105
+ env: GIT_ENV
106
+ });
107
+ const stdout = result.stdout;
108
+ return {
109
+ found: !notFound(result.error),
110
+ ok: result.error === void 0 && result.status === 0,
111
+ stdout: typeof stdout === "string" ? stdout : ""
112
+ };
113
+ };
114
+ var nodeGitLines = (args, cwd, onLine) => new Promise((resolve2) => {
115
+ const child = spawn("git", [...args], {
116
+ cwd,
117
+ windowsHide: true,
118
+ env: GIT_ENV,
119
+ stdio: ["ignore", "pipe", "ignore"]
120
+ });
121
+ let found = true;
122
+ child.on("error", (error) => {
123
+ found = !notFound(error);
124
+ });
125
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
126
+ lines.on("line", onLine);
127
+ child.on("close", (code) => {
128
+ resolve2({ found, ok: found && code === 0, stdout: "" });
129
+ });
130
+ });
131
+
41
132
  // src/core/node-runtime.ts
42
133
  var SAFE_TOKEN = /^[A-Za-z0-9._-]+$/;
43
134
  var WINDOWS_NOT_FOUND = 9009;
@@ -47,7 +138,7 @@ var errnoCode = (error) => {
47
138
  };
48
139
  var spawnProbe = (command, args, timeout) => {
49
140
  const options = { encoding: "utf8", timeout, windowsHide: true };
50
- return process.platform === "win32" ? spawnSync([command, ...args].join(" "), { ...options, shell: true }) : spawnSync(command, [...args], options);
141
+ return process.platform === "win32" ? spawnSync2([command, ...args].join(" "), { ...options, shell: true }) : spawnSync2(command, [...args], options);
51
142
  };
52
143
  var nodeProbe = (command, args, timeoutMs) => {
53
144
  if (![command, ...args].every((token) => SAFE_TOKEN.test(token))) {
@@ -86,7 +177,13 @@ var nodeRuntime = () => ({
86
177
  return () => {
87
178
  clearTimeout(timer);
88
179
  };
89
- }
180
+ },
181
+ writeText: nodeWriteText,
182
+ listDir: nodeListDir,
183
+ walkFiles: nodeWalkFiles,
184
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
185
+ git: nodeGit,
186
+ gitLines: nodeGitLines
90
187
  });
91
188
 
92
189
  // src/doctor/system.ts
@@ -301,6 +398,13 @@ var usageProblem = (code, messageKey, fix, params = {}) => ({
301
398
  fix,
302
399
  exitCode: ExitCode.Usage
303
400
  });
401
+ var needsPersonProblem = (messageKey, fix, params = {}) => ({
402
+ code: "needs_confirmation",
403
+ messageKey,
404
+ params,
405
+ fix,
406
+ exitCode: ExitCode.NeedsPerson
407
+ });
304
408
  var FmError = class extends Error {
305
409
  problem;
306
410
  constructor(problem) {
@@ -406,7 +510,7 @@ var GLOBAL_FLAGS = [
406
510
  { flag: "--help, -h", key: "flag.help" }
407
511
  ];
408
512
  var exitCodeHelp = (ctx) => EXIT_CODE_NAMES.map((name) => ({ code: ExitCode[name], name, summary: ctx.t(exitCodeDocs[ExitCode[name]]) }));
409
- var pad = (text, width) => text.padEnd(Math.max(width, text.length + 1));
513
+ var pad = (text2, width) => text2.padEnd(Math.max(width, text2.length + 1));
410
514
  var describe = (ctx, spec) => ({
411
515
  name: commandName(spec),
412
516
  usage: spec.usage,
@@ -504,9 +608,1044 @@ var parseDeadline = (raw) => {
504
608
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HOOK_DEADLINE_MS;
505
609
  };
506
610
 
611
+ // src/secrets/baseline.ts
612
+ import { join as join2 } from "node:path";
613
+ var BASELINE_FILE = ".finitemonkey/findings/baseline.json";
614
+ var BASELINE_SCHEMA = "finitemonkey/secrets-baseline@1";
615
+ var BASELINE_STATES = ["aceito", "falso-positivo"];
616
+ var EXCEPTION_MAX_DAYS = 90;
617
+ var EMPTY_BASELINE = { schema: BASELINE_SCHEMA, entries: [] };
618
+ var baselinePath = (root) => toAbsolutePath(join2(root, BASELINE_FILE));
619
+ var text = (record2, key) => {
620
+ const value = record2[key];
621
+ return typeof value === "string" && value !== "" ? value : void 0;
622
+ };
623
+ var stateOf = (value) => BASELINE_STATES.find((state) => state === value);
624
+ var parseEntry = (value) => {
625
+ if (!isRecord(value)) return void 0;
626
+ const fields = ["id", "rule", "file", "fingerprint", "owner", "reason", "acceptedAt", "expiresAt"];
627
+ const [id, rule, file, fingerprint, owner, reason, acceptedAt, expiresAt] = fields.map((key) => text(value, key));
628
+ const state = stateOf(value["state"]);
629
+ const line = value["line"];
630
+ if (id === void 0 || rule === void 0 || file === void 0 || fingerprint === void 0) return void 0;
631
+ if (owner === void 0 || reason === void 0 || acceptedAt === void 0 || expiresAt === void 0)
632
+ return void 0;
633
+ if (state === void 0 || typeof line !== "number") return void 0;
634
+ return { id, rule, file, line, fingerprint, state, owner, reason, acceptedAt, expiresAt };
635
+ };
636
+ var parseBaseline = (raw) => {
637
+ const parsed = parseJsonObject(raw);
638
+ if (!parsed.ok) return parsed;
639
+ if (parsed.value["schema"] !== BASELINE_SCHEMA) return reject(`schema must be ${BASELINE_SCHEMA}`);
640
+ const list2 = parsed.value["entries"];
641
+ if (!Array.isArray(list2)) return reject("entries must be a list");
642
+ const entries = [];
643
+ for (const [index, item] of list2.entries()) {
644
+ const entry = parseEntry(item);
645
+ if (entry === void 0) return reject(`entry ${String(index)} is incomplete`);
646
+ entries.push(entry);
647
+ }
648
+ return accept({ schema: BASELINE_SCHEMA, entries });
649
+ };
650
+ var loadBaseline = (runtime, root) => {
651
+ const raw = runtime.readText(baselinePath(root));
652
+ return raw.ok ? parseBaseline(raw.value) : accept(EMPTY_BASELINE);
653
+ };
654
+ var serializeBaseline = (baseline) => `${JSON.stringify({ schema: baseline.schema, entries: [...baseline.entries].sort((a, b) => a.id.localeCompare(b.id)) }, null, 2)}
655
+ `;
656
+ var saveBaseline = (runtime, root, baseline) => runtime.writeText(baselinePath(root), serializeBaseline(baseline));
657
+ var ratchet = (findings, baseline, now, inScope) => {
658
+ const byId = new Map(baseline.entries.map((entry) => [entry.id, entry]));
659
+ const seen = new Set(findings.map((finding) => finding.id));
660
+ const entryOf2 = (finding) => byId.get(finding.id);
661
+ return {
662
+ fresh: findings.filter((finding) => entryOf2(finding) === void 0),
663
+ known: findings.filter((finding) => (entryOf2(finding)?.expiresAt ?? "") >= now),
664
+ expired: findings.filter((finding) => {
665
+ const entry = entryOf2(finding);
666
+ return entry !== void 0 && entry.expiresAt < now;
667
+ }),
668
+ fixed: inScope === void 0 ? [] : baseline.entries.filter((entry) => !seen.has(entry.id) && inScope(entry.file))
669
+ };
670
+ };
671
+ var withoutFixed = (baseline, fixed) => {
672
+ const gone = new Set(fixed.map((entry) => entry.id));
673
+ return { schema: baseline.schema, entries: baseline.entries.filter((entry) => !gone.has(entry.id)) };
674
+ };
675
+ var acceptedFingerprints = (baseline, now) => new Set(baseline.entries.filter((entry) => entry.expiresAt >= now).map((entry) => entry.fingerprint));
676
+
677
+ // src/core/posix-path.ts
678
+ var toPosixPath = (path) => path.replace(/\\/g, "/");
679
+ var withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
680
+
681
+ // src/secrets/allowlist.ts
682
+ var PLACEHOLDER_WORDS = [
683
+ "example",
684
+ "placeholder",
685
+ "your_",
686
+ "your-",
687
+ "yourkey",
688
+ "yourtoken",
689
+ "yoursecret",
690
+ "_here",
691
+ "-here",
692
+ "changeme",
693
+ "change_me",
694
+ "change-me",
695
+ "replace",
696
+ "redacted",
697
+ "dummy",
698
+ "fake",
699
+ "sample",
700
+ "mock",
701
+ "fixture",
702
+ "test",
703
+ "todo",
704
+ "xxxx",
705
+ "****",
706
+ "....",
707
+ "…",
708
+ "<",
709
+ ">",
710
+ "${",
711
+ "{{",
712
+ "%s",
713
+ "process.env",
714
+ "secrets.",
715
+ "env("
716
+ ];
717
+ var PLACEHOLDER_VALUES = /* @__PURE__ */ new Set([
718
+ "password",
719
+ "passwd",
720
+ "secret",
721
+ "token",
722
+ "apikey",
723
+ "api_key",
724
+ "admin",
725
+ "root",
726
+ "postgres",
727
+ "changeit",
728
+ "user",
729
+ "pass"
730
+ ]);
731
+ var ENV_REFERENCE = /^\$\{?[A-Z_][A-Z0-9_]*\}?$/;
732
+ var LOCKFILES = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|go\.sum|Cargo\.lock|poetry\.lock|uv\.lock|composer\.lock|Gemfile\.lock)$/;
733
+ var TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|__fixtures__|fixtures|spec|testdata|examples?)\/|\.(?:test|spec)\.[A-Za-z0-9]+$/;
734
+ var isPlaceholder = (value) => {
735
+ const lower = value.toLowerCase();
736
+ if (PLACEHOLDER_VALUES.has(lower) || ENV_REFERENCE.test(value)) return true;
737
+ if (PLACEHOLDER_WORDS.some((word) => lower.includes(word))) return true;
738
+ return new Set(lower).size < 4;
739
+ };
740
+ var isTestOrLockPath = (path) => path !== void 0 && (LOCKFILES.test(toPosixPath(path)) || TEST_PATH.test(toPosixPath(path)));
741
+
742
+ // src/secrets/entropy.ts
743
+ var shannonEntropy = (text2) => {
744
+ if (text2.length === 0) return 0;
745
+ const counts = /* @__PURE__ */ new Map();
746
+ for (const char of text2) counts.set(char, (counts.get(char) ?? 0) + 1);
747
+ let entropy = 0;
748
+ for (const count2 of counts.values()) {
749
+ const p = count2 / text2.length;
750
+ entropy -= p * Math.log2(p);
751
+ }
752
+ return entropy;
753
+ };
754
+
755
+ // src/secrets/fingerprint.ts
756
+ import { createHash } from "node:crypto";
757
+ var sha256Hex = (text2) => createHash("sha256").update(text2).digest("hex");
758
+ var fingerprintOf = (secret) => sha256Hex(`finitemonkey:secret:${secret}`).slice(0, 16);
759
+ var ALNUM = /[A-Za-z0-9]/g;
760
+ var MIN_TAIL_LENGTH = 12;
761
+ var tailOf = (secret) => {
762
+ const visible = (secret.match(ALNUM) ?? []).join("");
763
+ return visible.length >= MIN_TAIL_LENGTH ? visible.slice(-4) : "****";
764
+ };
765
+
766
+ // src/secrets/lines.ts
767
+ var lineLocator = (text2) => {
768
+ const starts = [0];
769
+ for (let index = text2.indexOf("\n"); index !== -1; index = text2.indexOf("\n", index + 1)) starts.push(index + 1);
770
+ return (offset) => {
771
+ let low = 0;
772
+ let high = starts.length - 1;
773
+ while (low < high) {
774
+ const mid = low + high + 1 >> 1;
775
+ if ((starts[mid] ?? 0) <= offset) low = mid;
776
+ else high = mid - 1;
777
+ }
778
+ return { line: low + 1, column: offset - (starts[low] ?? 0) + 1 };
779
+ };
780
+ };
781
+
782
+ // src/secrets/rules/types.ts
783
+ var defineRule = (spec) => ({
784
+ id: spec.id,
785
+ summary: spec.summary,
786
+ kind: spec.kind ?? "credential",
787
+ severity: spec.severity ?? "high",
788
+ keywords: spec.keywords.map((keyword) => keyword.toLowerCase()),
789
+ pattern: new RegExp(spec.source, `gd${spec.flags ?? ""}`),
790
+ minLength: spec.minLength ?? 8,
791
+ minEntropy: spec.minEntropy ?? 0,
792
+ requireDigit: spec.requireDigit ?? false,
793
+ minRandomRun: spec.minRandomRun ?? 0,
794
+ ignoredNames: (spec.ignoredNames ?? []).map((name) => name.toLowerCase()),
795
+ skipTestPaths: spec.skipTestPaths ?? false,
796
+ placeholders: spec.placeholders ?? true
797
+ });
798
+
799
+ // src/secrets/rules/providers.ts
800
+ var PROVIDER_RULES = [
801
+ defineRule({
802
+ id: "aws-access-token",
803
+ summary: "AWS access key id (AKIA, ASIA, ABIA, ACCA, A3T)",
804
+ keywords: ["akia", "asia", "abia", "acca", "a3t"],
805
+ source: String.raw`\b(?<secret>(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b`,
806
+ minLength: 20,
807
+ minEntropy: 3
808
+ }),
809
+ defineRule({
810
+ id: "aws-secret-access-key",
811
+ summary: "AWS secret access key assigned to aws_secret_access_key or a similar name",
812
+ keywords: ["secret"],
813
+ source: String.raw`(?:aws_?)?secret_?access_?key["'\x60]?[ \t]*(?:=|:=|:|=>)[ \t]*["'\x60]?(?<secret>[A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])`,
814
+ flags: "i",
815
+ minLength: 40,
816
+ minEntropy: 4
817
+ }),
818
+ defineRule({
819
+ id: "github-pat",
820
+ summary: "GitHub classic personal access token (ghp_)",
821
+ keywords: ["ghp_"],
822
+ source: String.raw`\b(?<secret>ghp_[A-Za-z0-9]{36})(?![A-Za-z0-9])`,
823
+ minEntropy: 3
824
+ }),
825
+ defineRule({
826
+ id: "github-oauth",
827
+ summary: "GitHub OAuth access token (gho_)",
828
+ keywords: ["gho_"],
829
+ source: String.raw`\b(?<secret>gho_[A-Za-z0-9]{36})(?![A-Za-z0-9])`,
830
+ minEntropy: 3
831
+ }),
832
+ defineRule({
833
+ id: "github-app-token",
834
+ summary: "GitHub App user or installation token (ghu_, ghs_)",
835
+ keywords: ["ghu_", "ghs_"],
836
+ source: String.raw`\b(?<secret>(?:ghu|ghs)_[A-Za-z0-9]{36})(?![A-Za-z0-9])`,
837
+ minEntropy: 3
838
+ }),
839
+ defineRule({
840
+ id: "github-refresh-token",
841
+ summary: "GitHub refresh token (ghr_)",
842
+ keywords: ["ghr_"],
843
+ source: String.raw`\b(?<secret>ghr_[A-Za-z0-9]{36})(?![A-Za-z0-9])`,
844
+ minEntropy: 3
845
+ }),
846
+ defineRule({
847
+ id: "github-fine-grained-pat",
848
+ summary: "GitHub fine-grained personal access token (github_pat_)",
849
+ keywords: ["github_pat_"],
850
+ source: String.raw`\b(?<secret>github_pat_[A-Za-z0-9_]{82})(?![A-Za-z0-9_])`,
851
+ minEntropy: 3
852
+ }),
853
+ defineRule({
854
+ id: "gitlab-pat",
855
+ summary: "GitLab personal access token (glpat-)",
856
+ keywords: ["glpat-"],
857
+ source: String.raw`\b(?<secret>glpat-[A-Za-z0-9_-]{20})(?![A-Za-z0-9_-])`,
858
+ minEntropy: 3
859
+ }),
860
+ defineRule({
861
+ id: "npm-access-token",
862
+ summary: "npm access token (npm_)",
863
+ keywords: ["npm_"],
864
+ source: String.raw`\b(?<secret>npm_[A-Za-z0-9]{36})(?![A-Za-z0-9])`,
865
+ flags: "i",
866
+ minEntropy: 3
867
+ }),
868
+ defineRule({
869
+ id: "slack-bot-token",
870
+ summary: "Slack bot token (xoxb-)",
871
+ keywords: ["xoxb-"],
872
+ source: String.raw`\b(?<secret>xoxb-[0-9]{10,13}-[0-9]{10,13}[A-Za-z0-9-]*)`,
873
+ minEntropy: 3
874
+ }),
875
+ defineRule({
876
+ id: "slack-user-token",
877
+ summary: "Slack user or export token (xoxp-, xoxe-)",
878
+ keywords: ["xoxp-", "xoxe-"],
879
+ source: String.raw`\b(?<secret>xox[pe](?:-[0-9]{10,13}){3}-[A-Za-z0-9-]{28,34})`,
880
+ minEntropy: 3
881
+ }),
882
+ defineRule({
883
+ id: "slack-app-token",
884
+ summary: "Slack app-level token (xapp-)",
885
+ keywords: ["xapp-"],
886
+ source: String.raw`\b(?<secret>xapp-\d-[A-Z0-9]+-\d+-[a-z0-9]+)`,
887
+ flags: "i",
888
+ minEntropy: 3
889
+ }),
890
+ defineRule({
891
+ id: "slack-webhook-url",
892
+ summary: "Slack incoming webhook URL",
893
+ keywords: ["hooks.slack.com"],
894
+ source: String.raw`(?<secret>hooks\.slack\.com/(?:services|workflows|triggers)/[A-Za-z0-9+/]{43,56})`,
895
+ minEntropy: 3
896
+ }),
897
+ defineRule({
898
+ id: "stripe-access-token",
899
+ summary: "Stripe live secret or restricted key (sk_live_, rk_live_)",
900
+ keywords: ["sk_live_", "rk_live_", "sk_prod_", "rk_prod_"],
901
+ source: String.raw`\b(?<secret>(?:sk|rk)_(?:live|prod)_[A-Za-z0-9]{20,99})(?![A-Za-z0-9])`,
902
+ minEntropy: 3
903
+ }),
904
+ defineRule({
905
+ id: "anthropic-api-key",
906
+ summary: "Anthropic API or admin key (sk-ant-)",
907
+ keywords: ["sk-ant-"],
908
+ source: String.raw`\b(?<secret>sk-ant-(?:api|admin|oat)\d{2}-[A-Za-z0-9_-]{80,})`,
909
+ minEntropy: 3.5
910
+ }),
911
+ defineRule({
912
+ id: "openai-api-key",
913
+ summary: "OpenAI API key (sk-proj-, sk-svcacct-, sk-admin- or the T3BlbkFJ marker)",
914
+ keywords: ["t3blbkfj", "sk-proj-", "sk-svcacct-", "sk-admin-"],
915
+ source: String.raw`\b(?<secret>sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}|sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{58,})`,
916
+ minEntropy: 3.5
917
+ }),
918
+ defineRule({
919
+ id: "gcp-api-key",
920
+ summary: "Google API key (AIza)",
921
+ keywords: ["aiza"],
922
+ source: String.raw`\b(?<secret>AIza[0-9A-Za-z_-]{35})(?![0-9A-Za-z_-])`,
923
+ minEntropy: 3
924
+ }),
925
+ defineRule({
926
+ id: "asaas-api-key",
927
+ summary: "Asaas API key ($aact_, $aact_prod_, $aact_hmlg_) with a body; the bare prefix is a placeholder",
928
+ keywords: ["$aact_"],
929
+ source: String.raw`(?<secret>\$aact_(?:prod_|hmlg_)?[A-Za-z0-9+/=_:$-]{32,})`,
930
+ minLength: 40,
931
+ minEntropy: 3.5
932
+ })
933
+ ];
934
+
935
+ // src/secrets/rules/structural.ts
936
+ var STRUCTURAL_RULES = [
937
+ defineRule({
938
+ id: "private-key",
939
+ summary: "PEM private key block (RSA, EC, DSA, OPENSSH, PGP, PKCS#8)",
940
+ keywords: ["private key"],
941
+ source: String.raw`-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----(?<secret>[\s\S]{64,16384}?)-----END[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----`,
942
+ minLength: 64,
943
+ minEntropy: 4
944
+ }),
945
+ defineRule({
946
+ id: "jwt",
947
+ summary: "JSON Web Token with a header, a payload and a signature, checked for entropy",
948
+ keywords: ["eyj"],
949
+ source: String.raw`\b(?<secret>eyJ[A-Za-z0-9_-]{17,}\.eyJ[A-Za-z0-9_-]{17,}\.[A-Za-z0-9_-]{10,})`,
950
+ minLength: 40,
951
+ minEntropy: 3
952
+ }),
953
+ defineRule({
954
+ id: "credential-in-url",
955
+ summary: "Password or token inside a URL (scheme://user:secret@host), including git remotes",
956
+ keywords: ["://"],
957
+ source: String.raw`\b[a-z][a-z0-9+.-]{1,20}://[^\s:/@"'\x60]{1,64}:(?<secret>[^\s:/@"'\x60]{6,128})@[A-Za-z0-9.-]`,
958
+ flags: "i",
959
+ minLength: 6,
960
+ minEntropy: 2.5
961
+ }),
962
+ defineRule({
963
+ id: "default-secret-fallback",
964
+ summary: "JavaScript secret variable with a constant fallback (process.env.X ?? 'constant')",
965
+ kind: "practice",
966
+ severity: "medium",
967
+ keywords: ["process.env."],
968
+ source: String.raw`process\.env\.[A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*[ \t]*(?:\?\?|\|\|)[ \t]*["'\x60](?<secret>[^"'\x60\n]{4,})["'\x60]`,
969
+ minLength: 4,
970
+ placeholders: false,
971
+ skipTestPaths: true
972
+ }),
973
+ defineRule({
974
+ id: "default-secret-fallback-python",
975
+ summary: "Python secret variable with a constant default (os.getenv('X', 'constant'))",
976
+ kind: "practice",
977
+ severity: "medium",
978
+ keywords: ["getenv", "environ.get"],
979
+ source: String.raw`os\.(?:getenv|environ\.get)\([ \t]*["'][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Za-z0-9_]*["'][ \t]*,[ \t]*["'](?<secret>[^"'\n]{4,})["']`,
980
+ minLength: 4,
981
+ placeholders: false,
982
+ skipTestPaths: true
983
+ }),
984
+ defineRule({
985
+ id: "generic-api-key",
986
+ summary: "High-entropy value assigned to a name with key, secret, token or credential",
987
+ keywords: ["key", "secret", "token", "credential"],
988
+ source: String.raw`(?:key|secret|token|credential)s?[A-Za-z0-9_.-]{0,30}["'\x60]?[ \t]*(?:=|:=|:|=>)[ \t]*["'\x60]?(?<secret>[A-Za-z0-9_\-+/=.~$!@#%^&*]{16,200})(?![A-Za-z0-9_\-+/=.~$!@#%^&*(])`,
989
+ flags: "i",
990
+ minLength: 16,
991
+ minEntropy: 3.5,
992
+ requireDigit: true,
993
+ minRandomRun: 12,
994
+ ignoredNames: [
995
+ "key",
996
+ "keys",
997
+ "cachekey",
998
+ "querykey",
999
+ "storagekey",
1000
+ "sortkey",
1001
+ "idempotencykey",
1002
+ "keyname",
1003
+ "tokentype"
1004
+ ],
1005
+ skipTestPaths: true
1006
+ }),
1007
+ defineRule({
1008
+ id: "password-assignment",
1009
+ summary: "Literal value assigned to a password name (password, passwd, pwd, senha, contraseña)",
1010
+ keywords: ["passw", "pwd", "senha", "contrase"],
1011
+ source: String.raw`(?:password|passwd|pwd|senha|contrase[ñn]a)[A-Za-z0-9_.-]{0,20}["'\x60]?[ \t]*(?:=|:=|:|=>)[ \t]*["'\x60]?(?<secret>[^\s"'\x60,;()[\]{}<>]{8,128})`,
1012
+ flags: "i",
1013
+ minLength: 8,
1014
+ minEntropy: 3,
1015
+ requireDigit: true,
1016
+ skipTestPaths: true
1017
+ })
1018
+ ];
1019
+
1020
+ // src/secrets/rules/catalog.ts
1021
+ var RULES = [...PROVIDER_RULES, ...STRUCTURAL_RULES];
1022
+ var CREDENTIAL_RULES = RULES.filter((rule) => rule.kind === "credential");
1023
+ var ruleById = (id) => RULES.find((rule) => rule.id === id);
1024
+
1025
+ // src/secrets/engine.ts
1026
+ var DIGIT = /[0-9]/;
1027
+ var LETTER = /[A-Za-z]/;
1028
+ var RUN_ENTROPY = 3;
1029
+ var hasRandomRun = (secret, length) => (secret.match(new RegExp(`[A-Za-z0-9]{${String(length)},}`, "g")) ?? []).some(
1030
+ (run2) => DIGIT.test(run2) && LETTER.test(run2) && shannonEntropy(run2) >= RUN_ENTROPY
1031
+ );
1032
+ var accepted = (rule, secret) => secret.length >= rule.minLength && (!rule.requireDigit || DIGIT.test(secret)) && (rule.minRandomRun === 0 || hasRandomRun(secret, rule.minRandomRun)) && (!rule.placeholders || !isPlaceholder(secret)) && (rule.minEntropy === 0 || shannonEntropy(secret) >= rule.minEntropy);
1033
+ var NAME_CHAR = /[A-Za-z0-9_.-]/;
1034
+ var nameAround = (text2, at) => {
1035
+ let start = at;
1036
+ while (start > 0 && NAME_CHAR.test(text2.charAt(start - 1))) start -= 1;
1037
+ let end = at;
1038
+ while (end < text2.length && NAME_CHAR.test(text2.charAt(end))) end += 1;
1039
+ return text2.slice(start, end).toLowerCase();
1040
+ };
1041
+ var ignoredName = (rule, match) => rule.ignoredNames.length > 0 && rule.ignoredNames.includes(nameAround(match.input, match.index));
1042
+ var spanOf = (rule, match) => {
1043
+ const range = match.indices?.groups?.["secret"] ?? match.indices?.[0];
1044
+ if (range === void 0 || ignoredName(rule, match)) return void 0;
1045
+ const [start, end] = range;
1046
+ const secret = match.input.slice(start, end);
1047
+ return accepted(rule, secret) ? { rule, start, end, secret } : void 0;
1048
+ };
1049
+ var ruleSpans = (rule, text2) => {
1050
+ const spans = [];
1051
+ for (const match of text2.matchAll(rule.pattern)) {
1052
+ const span = spanOf(rule, match);
1053
+ if (span !== void 0) spans.push(span);
1054
+ }
1055
+ return spans;
1056
+ };
1057
+ var overlaps = (taken, span) => taken.some((other) => span.start < other.end && other.start < span.end);
1058
+ var applicable = (rules, lower, path) => {
1059
+ const testPath = isTestOrLockPath(path);
1060
+ return rules.filter(
1061
+ (rule) => !(testPath && rule.skipTestPaths) && rule.keywords.some((keyword) => lower.includes(keyword))
1062
+ );
1063
+ };
1064
+ var toFinding = (span, locate2) => {
1065
+ const position = locate2(span.start);
1066
+ return {
1067
+ ruleId: span.rule.id,
1068
+ severity: span.rule.severity,
1069
+ line: position.line,
1070
+ column: position.column,
1071
+ start: span.start,
1072
+ end: span.end,
1073
+ fingerprint: fingerprintOf(span.secret),
1074
+ tail: tailOf(span.secret)
1075
+ };
1076
+ };
1077
+ var scanText = (text2, options = {}) => {
1078
+ if (text2.length === 0) return [];
1079
+ const rules = applicable(options.rules ?? RULES, text2.toLowerCase(), options.path);
1080
+ if (rules.length === 0) return [];
1081
+ const taken = [];
1082
+ for (const rule of rules) {
1083
+ for (const span of ruleSpans(rule, text2)) if (!overlaps(taken, span)) taken.push(span);
1084
+ }
1085
+ if (taken.length === 0) return [];
1086
+ const locate2 = lineLocator(text2);
1087
+ return taken.sort((a, b) => a.start - b.start).map((span) => toFinding(span, locate2));
1088
+ };
1089
+
1090
+ // src/secrets/incidents.ts
1091
+ import { join as join3 } from "node:path";
1092
+ var INCIDENTS_DIR = ".finitemonkey/incidents";
1093
+ var INCIDENT_STATES = ["aberto", "exposicao-local", "rotacionado", "fechado"];
1094
+ var INCIDENT_SOURCES = ["tool-output", "prompt", "history", "scan"];
1095
+ var ID = /^inc-[0-9a-f]{12}$/;
1096
+ var incidentId = (rule, fingerprint) => `inc-${sha256Hex(`${rule}\0${fingerprint}`).slice(0, 12)}`;
1097
+ var incidentsDir = (root) => toAbsolutePath(join3(root, INCIDENTS_DIR));
1098
+ var incidentFile = (root, id) => toAbsolutePath(join3(incidentsDir(root), `${id}.json`));
1099
+ var pick = (options, value) => options.find((option) => option === value);
1100
+ var str = (record2, key) => {
1101
+ const value = record2[key];
1102
+ return typeof value === "string" ? value : void 0;
1103
+ };
1104
+ var parseEvent = (value) => {
1105
+ if (!isRecord(value)) return void 0;
1106
+ const state = pick(INCIDENT_STATES, value["state"]);
1107
+ const at = str(value, "at");
1108
+ const by = str(value, "by");
1109
+ return state === void 0 || at === void 0 || by === void 0 ? void 0 : { state, at, by };
1110
+ };
1111
+ var parseHistory = (value) => {
1112
+ if (!Array.isArray(value)) return void 0;
1113
+ const events = value.map(parseEvent);
1114
+ return events.every((event) => event !== void 0) ? events : void 0;
1115
+ };
1116
+ var parseIncident = (raw) => {
1117
+ const parsed = parseJsonObject(raw);
1118
+ if (!parsed.ok) return parsed;
1119
+ const record2 = parsed.value;
1120
+ const [id, rule, where, fingerprint, createdAt, updatedAt] = ["id", "rule", "where", "fingerprint", "createdAt", "updatedAt"].map((key) => str(record2, key));
1121
+ const state = pick(INCIDENT_STATES, record2["state"]);
1122
+ const source = pick(INCIDENT_SOURCES, record2["source"]);
1123
+ const history = parseHistory(record2["history"]);
1124
+ const firstCommit = str(record2, "firstCommit");
1125
+ if (id === void 0 || !ID.test(id) || rule === void 0 || where === void 0) return reject("incomplete");
1126
+ if (fingerprint === void 0 || createdAt === void 0 || updatedAt === void 0) return reject("incomplete");
1127
+ if (state === void 0 || source === void 0 || history === void 0) return reject("incomplete");
1128
+ const base = {
1129
+ schemaVersion: 1,
1130
+ id,
1131
+ rule,
1132
+ where,
1133
+ fingerprint,
1134
+ source,
1135
+ state,
1136
+ createdAt,
1137
+ updatedAt,
1138
+ history
1139
+ };
1140
+ return accept(firstCommit === void 0 ? base : { ...base, firstCommit });
1141
+ };
1142
+ var readIncident = (runtime, root, id) => {
1143
+ if (!ID.test(id)) return reject("not an incident id");
1144
+ const raw = runtime.readText(incidentFile(root, id));
1145
+ return raw.ok ? parseIncident(raw.value) : reject(raw.error);
1146
+ };
1147
+ var listIncidents = (runtime, root) => {
1148
+ const names = runtime.listDir(incidentsDir(root));
1149
+ if (!names.ok) return [];
1150
+ return names.value.filter((name) => name.endsWith(".json")).map((name) => readIncident(runtime, root, name.slice(0, -".json".length))).flatMap((parsed) => parsed.ok ? [parsed.value] : []).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1151
+ };
1152
+ var saveIncident = (runtime, root, incident) => {
1153
+ const ignore = runtime.writeText(toAbsolutePath(join3(incidentsDir(root), ".gitignore")), "*\n");
1154
+ if (!ignore.ok) return ignore;
1155
+ return runtime.writeText(incidentFile(root, incident.id), `${JSON.stringify(incident, null, 2)}
1156
+ `);
1157
+ };
1158
+ var created = (draft, now, by) => ({
1159
+ schemaVersion: 1,
1160
+ id: incidentId(draft.rule, draft.fingerprint),
1161
+ rule: draft.rule,
1162
+ where: draft.where,
1163
+ fingerprint: draft.fingerprint,
1164
+ source: draft.source,
1165
+ state: draft.state,
1166
+ createdAt: now,
1167
+ updatedAt: now,
1168
+ ...draft.firstCommit === void 0 ? {} : { firstCommit: draft.firstCommit },
1169
+ history: [{ state: draft.state, at: now, by }]
1170
+ });
1171
+ var recordIncident = (runtime, root, draft, by) => {
1172
+ const now = runtime.now();
1173
+ const existing = readIncident(runtime, root, incidentId(draft.rule, draft.fingerprint));
1174
+ if (existing.ok && existing.value.state !== "fechado") return existing;
1175
+ const incident = existing.ok ? {
1176
+ ...existing.value,
1177
+ state: draft.state,
1178
+ updatedAt: now,
1179
+ history: [...existing.value.history, { state: draft.state, at: now, by }]
1180
+ } : created(draft, now, by);
1181
+ const saved = saveIncident(runtime, root, incident);
1182
+ return saved.ok ? accept(incident) : reject(saved.error);
1183
+ };
1184
+ var ALLOWED_FROM = {
1185
+ aberto: [],
1186
+ "exposicao-local": [],
1187
+ rotacionado: ["aberto", "exposicao-local"],
1188
+ fechado: ["rotacionado"]
1189
+ };
1190
+ var canMove = (from, to) => ALLOWED_FROM[to].includes(from);
1191
+ var moveIncident = (incident, to, now, by) => ({
1192
+ ...incident,
1193
+ state: to,
1194
+ updatedAt: now,
1195
+ history: [...incident.history, { state: to, at: now, by }]
1196
+ });
1197
+ var isRotated = (incident) => incident.state === "rotacionado" || incident.state === "fechado";
1198
+
1199
+ // src/secrets/guards/describe.ts
1200
+ var MAX_LISTED = 5;
1201
+ var describeFindings = (t, items) => {
1202
+ const listed2 = items.slice(0, MAX_LISTED).map(({ ruleId, where }) => t("secrets.found_item", { rule: ruleId, where }));
1203
+ const rest = items.length - listed2.length;
1204
+ return rest > 0 ? `${listed2.join("; ")}; ${t("secrets.found_more", { count: rest })}` : listed2.join("; ");
1205
+ };
1206
+ var lineWhere = (t, finding) => t("secrets.where.line", { line: finding.line, column: finding.column });
1207
+
1208
+ // src/secrets/guards/tool-input.ts
1209
+ var toolName = (payload) => optionalString(payload.raw, "tool_name") ?? "";
1210
+ var toolInput = (payload) => {
1211
+ const input = payload.raw["tool_input"];
1212
+ return isRecord(input) ? input : {};
1213
+ };
1214
+ var editTexts = (input, file) => {
1215
+ const edits = input["edits"];
1216
+ if (!Array.isArray(edits)) return [];
1217
+ return edits.flatMap((edit) => {
1218
+ if (!isRecord(edit)) return [];
1219
+ const text2 = optionalString(edit, "new_string");
1220
+ return text2 === void 0 ? [] : [{ file, text: text2, whole: false }];
1221
+ });
1222
+ };
1223
+ var writtenTexts = (tool, input) => {
1224
+ const file = optionalString(input, "file_path") ?? optionalString(input, "notebook_path") ?? "";
1225
+ const single = (key, whole) => {
1226
+ const text2 = optionalString(input, key);
1227
+ return text2 === void 0 ? [] : [{ file, text: text2, whole }];
1228
+ };
1229
+ switch (tool) {
1230
+ case "Write":
1231
+ return single("content", true);
1232
+ case "Edit":
1233
+ return single("new_string", false);
1234
+ case "MultiEdit":
1235
+ return editTexts(input, file);
1236
+ case "NotebookEdit":
1237
+ return single("new_source", false);
1238
+ default:
1239
+ return [];
1240
+ }
1241
+ };
1242
+ var stringLeaves = (value, out = []) => {
1243
+ if (typeof value === "string") out.push(value);
1244
+ else if (Array.isArray(value)) for (const item of value) stringLeaves(item, out);
1245
+ else if (isRecord(value)) for (const item of Object.values(value)) stringLeaves(item, out);
1246
+ return out;
1247
+ };
1248
+
1249
+ // src/secrets/guards/exposure.ts
1250
+ var HOOK_ACTOR = "finitemonkey";
1251
+ var liveFindings = (input, texts) => {
1252
+ const baseline = loadBaseline(input.runtime, input.cwd);
1253
+ const accepted2 = acceptedFingerprints(baseline.ok ? baseline.value : EMPTY_BASELINE, input.runtime.now());
1254
+ return texts.flatMap((text2) => scanText(text2, { rules: CREDENTIAL_RULES })).filter((finding) => !accepted2.has(finding.fingerprint));
1255
+ };
1256
+ var record = (input, findings, draft, where) => {
1257
+ const ids = findings.map((finding) => {
1258
+ const saved = recordIncident(
1259
+ input.runtime,
1260
+ input.cwd,
1261
+ { ...draft, rule: finding.ruleId, fingerprint: finding.fingerprint, where: `${where}:${String(finding.line)}` },
1262
+ HOOK_ACTOR
1263
+ );
1264
+ return saved.ok ? saved.value.id : input.t("secrets.incident.unsaved", { detail: saved.error });
1265
+ });
1266
+ return [...new Set(ids)].join(", ");
1267
+ };
1268
+ var secretsUserPromptSubmit = (input) => {
1269
+ const prompt = optionalString(input.payload.raw, "prompt") ?? "";
1270
+ const findings = liveFindings(input, [prompt]);
1271
+ if (findings.length === 0) return { kind: "allow" };
1272
+ const incident = record(input, findings, { source: "prompt", state: "exposicao-local" }, "prompt");
1273
+ const items = findings.map((finding) => ({ ruleId: finding.ruleId, where: lineWhere(input.t, finding) }));
1274
+ return {
1275
+ kind: "block",
1276
+ reason: input.t("secrets.block.prompt", { found: describeFindings(input.t, items), incident })
1277
+ };
1278
+ };
1279
+ var secretsPostToolUse = (input) => {
1280
+ const findings = liveFindings(input, stringLeaves(input.payload.raw["tool_response"]));
1281
+ if (findings.length === 0) return { kind: "allow" };
1282
+ const tool = toolName(input.payload) || "tool";
1283
+ const incident = record(input, findings, { source: "tool-output", state: "aberto" }, `tool-output:${tool}`);
1284
+ const items = findings.map((finding) => ({ ruleId: finding.ruleId, where: lineWhere(input.t, finding) }));
1285
+ const context = input.t("secrets.post.context", { tool, found: describeFindings(input.t, items), incident });
1286
+ return {
1287
+ kind: "allow",
1288
+ output: { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: context } }
1289
+ };
1290
+ };
1291
+
1292
+ // src/secrets/guards/pre-tool.ts
1293
+ import { relative as relative3 } from "node:path";
1294
+
1295
+ // src/secrets/scan.ts
1296
+ import { join as join4, relative as relative2 } from "node:path";
1297
+
1298
+ // src/secrets/located.ts
1299
+ var findingId = (ruleId, file, fingerprint) => `fm-${sha256Hex(`${ruleId}\0${file}\0${fingerprint}`).slice(0, 16)}`;
1300
+ var locate = (finding, rawFile, line, commit) => {
1301
+ const file = toPosixPath(rawFile);
1302
+ return {
1303
+ id: findingId(finding.ruleId, file, finding.fingerprint),
1304
+ ruleId: finding.ruleId,
1305
+ severity: finding.severity,
1306
+ file,
1307
+ line,
1308
+ column: finding.column,
1309
+ fingerprint: finding.fingerprint,
1310
+ tail: finding.tail,
1311
+ ...commit === void 0 ? {} : { commit }
1312
+ };
1313
+ };
1314
+ var whereOf = (finding) => `${finding.file}:${String(finding.line)}`;
1315
+
1316
+ // src/secrets/diff.ts
1317
+ var COMMIT = /^commit ([0-9a-f]{7,64})\b/;
1318
+ var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
1319
+ var DEV_NULL = "/dev/null";
1320
+ var fresh = () => ({ file: void 0, inHunk: false, next: 0, text: [], lines: [] });
1321
+ var targetOf = (line) => {
1322
+ const path = line.slice(4).replace(/\t.*$/, "");
1323
+ if (path === DEV_NULL) return void 0;
1324
+ return path.startsWith("b/") ? path.slice(2) : path;
1325
+ };
1326
+ var collect = (chunk, commit, found) => {
1327
+ const { file } = chunk;
1328
+ if (file === void 0 || chunk.text.length === 0) return;
1329
+ for (const finding of scanText(chunk.text.join("\n"), { path: file })) {
1330
+ const located = locate(finding, file, chunk.lines[finding.line - 1] ?? finding.line, commit);
1331
+ found.set(located.id, located);
1332
+ }
1333
+ };
1334
+ var addLine = (chunk, line) => {
1335
+ const hunk = HUNK.exec(line);
1336
+ if (hunk !== null) {
1337
+ chunk.inHunk = true;
1338
+ chunk.next = Number(hunk[1]);
1339
+ } else if (!chunk.inHunk && line.startsWith("+++ ")) {
1340
+ chunk.file = targetOf(line);
1341
+ } else if (chunk.inHunk && line.startsWith("+")) {
1342
+ chunk.text.push(line.slice(1));
1343
+ chunk.lines.push(chunk.next);
1344
+ chunk.next += 1;
1345
+ } else if (chunk.inHunk && line.startsWith(" ")) {
1346
+ chunk.next += 1;
1347
+ }
1348
+ };
1349
+ var diffScanner = () => {
1350
+ const found = /* @__PURE__ */ new Map();
1351
+ let commit;
1352
+ let chunk = fresh();
1353
+ const flush2 = () => {
1354
+ collect(chunk, commit, found);
1355
+ chunk = fresh();
1356
+ };
1357
+ const header = (line) => {
1358
+ const match = COMMIT.exec(line);
1359
+ if (match !== null) {
1360
+ flush2();
1361
+ commit = match[1];
1362
+ return true;
1363
+ }
1364
+ if (line.startsWith("diff --git ")) {
1365
+ flush2();
1366
+ return true;
1367
+ }
1368
+ return false;
1369
+ };
1370
+ return {
1371
+ push: (raw) => {
1372
+ const line = withoutCarriageReturn(raw);
1373
+ if (!header(line)) addLine(chunk, line);
1374
+ },
1375
+ end: () => {
1376
+ flush2();
1377
+ return [...found.values()];
1378
+ }
1379
+ };
1380
+ };
1381
+
1382
+ // src/secrets/scan.ts
1383
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
1384
+ var SKIPPED = /(?:^|\/)(?:\.git|node_modules|\.finitemonkey)\//;
1385
+ var DIFF_FLAGS = ["-U0", "--no-color", "--no-ext-diff", "--no-renames"];
1386
+ var binary = (text2) => text2.slice(0, 8e3).includes("\0");
1387
+ var listed = (runtime, root, target) => {
1388
+ const git = runtime.git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", target], root);
1389
+ if (git.ok)
1390
+ return accept(
1391
+ git.stdout.split("\0").filter((name) => name !== "").map(toPosixPath)
1392
+ );
1393
+ const base = toAbsolutePath(target, root);
1394
+ const walked = runtime.walkFiles(base);
1395
+ if (!walked.ok) return walked;
1396
+ return accept(walked.value.map((name) => toPosixPath(relative2(root, join4(base, toPosixPath(name))))));
1397
+ };
1398
+ var scanFile = (runtime, root, file) => {
1399
+ const text2 = runtime.readText(toAbsolutePath(file, root));
1400
+ if (!text2.ok || text2.value.length > MAX_FILE_BYTES || binary(text2.value)) return [];
1401
+ return scanText(text2.value, { path: file }).map((finding) => locate(finding, file, finding.line));
1402
+ };
1403
+ var scanPath = (runtime, root, target) => {
1404
+ const files = listed(runtime, root, target);
1405
+ if (!files.ok) return files;
1406
+ return accept(files.value.filter((file) => !SKIPPED.test(file)).flatMap((file) => scanFile(runtime, root, file)));
1407
+ };
1408
+ var diffFindings = (diff) => {
1409
+ const scanner = diffScanner();
1410
+ for (const line of diff.split("\n")) scanner.push(line);
1411
+ return scanner.end().filter((finding) => !SKIPPED.test(finding.file));
1412
+ };
1413
+ var scanStaged = (runtime, root, includeUnstaged = false) => {
1414
+ const args = includeUnstaged ? ["diff", "HEAD", ...DIFF_FLAGS] : ["diff", "--cached", ...DIFF_FLAGS];
1415
+ const git = runtime.git(args, root);
1416
+ if (!git.ok) return reject(git.found ? "git diff failed" : "git not found");
1417
+ return accept(diffFindings(git.stdout));
1418
+ };
1419
+ var scanHistory = async (runtime, root) => {
1420
+ const scanner = diffScanner();
1421
+ const args = ["log", "-p", "--all", "--format=commit %H", ...DIFF_FLAGS];
1422
+ const git = await runtime.gitLines(args, root, scanner.push);
1423
+ if (!git.ok) return reject(git.found ? "git log failed" : "git not found");
1424
+ return accept(scanner.end().filter((finding) => !SKIPPED.test(finding.file)));
1425
+ };
1426
+
1427
+ // src/secrets/guards/paths.ts
1428
+ var SAFE_ENV = /^\.env\.(?:example|sample|template|dist|defaults|schema)$/i;
1429
+ var ENV_FILE = /^\.env(?:\..+)?$/i;
1430
+ var KEY_FILE = /\.(?:pem|key|p12|pfx)$/i;
1431
+ var SSH_KEY = /^id_(?:rsa|dsa|ecdsa|ed25519)(?:_[A-Za-z0-9_-]+)?$/;
1432
+ var basename = (path) => path.split(/[\\/]/).pop() ?? path;
1433
+ var isSensitivePath = (path) => {
1434
+ const name = basename(path.replace(/^["']|["']$/g, ""));
1435
+ if (ENV_FILE.test(name)) return !SAFE_ENV.test(name);
1436
+ return KEY_FILE.test(name) || SSH_KEY.test(name);
1437
+ };
1438
+
1439
+ // src/secrets/guards/bash-rules.ts
1440
+ var READERS = /* @__PURE__ */ new Set([
1441
+ "cat",
1442
+ "bat",
1443
+ "batcat",
1444
+ "less",
1445
+ "more",
1446
+ "head",
1447
+ "tail",
1448
+ "nl",
1449
+ "tac",
1450
+ "strings",
1451
+ "xxd",
1452
+ "hexdump",
1453
+ "od",
1454
+ "grep",
1455
+ "egrep",
1456
+ "fgrep",
1457
+ "rg",
1458
+ "ag",
1459
+ "ack",
1460
+ "awk",
1461
+ "gawk",
1462
+ "sed",
1463
+ "cut",
1464
+ "sort",
1465
+ "uniq",
1466
+ "jq",
1467
+ "yq",
1468
+ "base64",
1469
+ "type",
1470
+ "get-content",
1471
+ "gc",
1472
+ "select-string",
1473
+ "sls",
1474
+ "diff",
1475
+ "vim",
1476
+ "vi",
1477
+ "nano",
1478
+ "view",
1479
+ "dotenv"
1480
+ ]);
1481
+ var SECRET_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PASS|PWD|CREDENTIAL)/i;
1482
+ var PERSON_ONLY = /\b(?:finitemonkey|fm)(?:\.mjs)?\s+secrets\s+(?:accept\b|incident\s+(?:rotated|close)\b)/;
1483
+ var LISTS_ENV = /* @__PURE__ */ new Set(["get-childitem", "gci", "dir", "ls"]);
1484
+ var onlyFlags = (args) => args.every((arg) => arg.startsWith("-"));
1485
+ var readTarget = (segment) => {
1486
+ const redirected = segment.redirectedFrom.find(isSensitivePath);
1487
+ if (redirected !== void 0) return redirected;
1488
+ const program = segment.program.toLowerCase();
1489
+ if (!READERS.has(program)) return void 0;
1490
+ if (program === "sed" && segment.args.some((arg) => arg.startsWith("-i"))) return void 0;
1491
+ return segment.args.find((arg) => !arg.startsWith("-") && isSensitivePath(arg));
1492
+ };
1493
+ var dumpsEnv = (segment) => {
1494
+ const program = segment.program.toLowerCase();
1495
+ if (program === "env" || program === "printenv") {
1496
+ if (onlyFlags(segment.args)) return true;
1497
+ return program === "printenv" && segment.args.some((arg) => SECRET_NAME.test(arg));
1498
+ }
1499
+ if (program === "set" || program === "export" || program === "declare") {
1500
+ return segment.args.length === 0 || segment.args.every((arg) => arg === "-p" || arg === "-x");
1501
+ }
1502
+ return LISTS_ENV.has(program) && segment.args.some((arg) => /^env:/i.test(arg));
1503
+ };
1504
+ var bashFindings = (command, segments) => {
1505
+ const person = PERSON_ONLY.exec(command);
1506
+ if (person !== null) return [{ kind: "person-only", command: person[0] }];
1507
+ return segments.flatMap((segment) => {
1508
+ const target = readTarget(segment);
1509
+ if (target !== void 0) return [{ kind: "read", target }];
1510
+ if (dumpsEnv(segment)) return [{ kind: "env-dump", command: [segment.program, ...segment.args].join(" ") }];
1511
+ return [];
1512
+ });
1513
+ };
1514
+ var GIT_GLOBALS_WITH_VALUE = /* @__PURE__ */ new Set(["-C", "-c", "--git-dir", "--work-tree"]);
1515
+ var commitIntent = (segments) => {
1516
+ for (const segment of segments) {
1517
+ if (segment.program !== "git") continue;
1518
+ let index = 0;
1519
+ while (index < segment.args.length && (segment.args[index] ?? "").startsWith("-")) {
1520
+ index += GIT_GLOBALS_WITH_VALUE.has(segment.args[index] ?? "") ? 2 : 1;
1521
+ }
1522
+ if (segment.args[index] !== "commit") continue;
1523
+ const flags = segment.args.slice(index + 1);
1524
+ const all = flags.some((flag2) => flag2 === "--all" || /^-[A-Za-z]*a[A-Za-z]*$/.test(flag2));
1525
+ return { includeUnstaged: all };
1526
+ }
1527
+ return void 0;
1528
+ };
1529
+
1530
+ // src/secrets/guards/shell.ts
1531
+ var SEPARATORS = /\|\||&&|[|;&\n]/;
1532
+ var TOKEN = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
1533
+ var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
1534
+ var OUTPUT_REDIRECT = /^(?:\d|&)?>{1,2}\|?$/;
1535
+ var OUTPUT_ATTACHED = /^(?:\d|&)?>{1,2}\|?\S/;
1536
+ var WRAPPERS = /* @__PURE__ */ new Set(["sudo", "command", "exec", "time", "nice", "nohup", "builtin"]);
1537
+ var tokensOf = (text2) => Array.from(text2.matchAll(TOKEN), (match) => match[1] ?? match[2] ?? match[3] ?? "");
1538
+ var splitRedirects = (tokens) => {
1539
+ const words = [];
1540
+ const inputs = [];
1541
+ for (let index = 0; index < tokens.length; index += 1) {
1542
+ const token = tokens[index] ?? "";
1543
+ if (OUTPUT_REDIRECT.test(token)) {
1544
+ index += 1;
1545
+ } else if (OUTPUT_ATTACHED.test(token)) {
1546
+ continue;
1547
+ } else if (token === "<") {
1548
+ inputs.push(tokens[index + 1] ?? "");
1549
+ index += 1;
1550
+ } else if (token.startsWith("<") && !token.startsWith("<<")) {
1551
+ inputs.push(token.slice(1));
1552
+ } else {
1553
+ words.push(token);
1554
+ }
1555
+ }
1556
+ return { words, inputs };
1557
+ };
1558
+ var segmentOf = (text2) => {
1559
+ const { words, inputs } = splitRedirects(tokensOf(text2));
1560
+ let index = 0;
1561
+ while (index < words.length && (ASSIGNMENT.test(words[index] ?? "") || WRAPPERS.has(words[index] ?? ""))) index += 1;
1562
+ const program = words[index];
1563
+ if (program === void 0) return inputs.length > 0 ? { program: "", args: [], redirectedFrom: inputs } : void 0;
1564
+ return { program: program.split(/[\\/]/).pop() ?? program, args: words.slice(index + 1), redirectedFrom: inputs };
1565
+ };
1566
+ var segmentsOf = (command) => command.split(SEPARATORS).flatMap((part) => {
1567
+ const segment = segmentOf(part);
1568
+ return segment === void 0 ? [] : [segment];
1569
+ });
1570
+
1571
+ // src/secrets/guards/pre-tool.ts
1572
+ var ALLOW = { kind: "allow" };
1573
+ var relativeFile = (cwd, file) => {
1574
+ if (file === "") return file;
1575
+ const rel = relative3(cwd, file).split("\\").join("/");
1576
+ return rel.startsWith("..") || rel === "" ? file : rel;
1577
+ };
1578
+ var writeDecision = (input, tool) => {
1579
+ const { t } = input;
1580
+ for (const written of writtenTexts(tool, toolInput(input.payload))) {
1581
+ const file = relativeFile(input.cwd, written.file);
1582
+ const found = scanText(written.text, { path: file });
1583
+ if (found.length === 0) continue;
1584
+ const items = found.map((finding) => ({
1585
+ ruleId: finding.ruleId,
1586
+ where: written.whole ? t("secrets.where.file", { file, line: finding.line, column: finding.column }) : t("secrets.where.new_text", { file, line: finding.line })
1587
+ }));
1588
+ return { kind: "block", reason: t("secrets.block.write", { file, found: describeFindings(t, items) }) };
1589
+ }
1590
+ return ALLOW;
1591
+ };
1592
+ var bashMessage = (t, finding) => {
1593
+ switch (finding.kind) {
1594
+ case "read":
1595
+ return t("secrets.block.read", { target: finding.target });
1596
+ case "env-dump":
1597
+ return t("secrets.block.env_dump", { command: finding.command });
1598
+ case "person-only":
1599
+ return t("secrets.block.person_only", { command: finding.command });
1600
+ }
1601
+ };
1602
+ var commitDecision = (input, includeUnstaged) => {
1603
+ const staged = scanStaged(input.runtime, input.cwd, includeUnstaged);
1604
+ if (!staged.ok || staged.value.length === 0) return ALLOW;
1605
+ const items = staged.value.map((finding) => ({
1606
+ ruleId: finding.ruleId,
1607
+ where: input.t("secrets.where.file", { file: finding.file, line: finding.line, column: finding.column })
1608
+ }));
1609
+ return { kind: "block", reason: input.t("secrets.block.commit", { found: describeFindings(input.t, items) }) };
1610
+ };
1611
+ var bashDecision = (input) => {
1612
+ const command = optionalString(toolInput(input.payload), "command") ?? "";
1613
+ if (command === "") return ALLOW;
1614
+ const segments = segmentsOf(command);
1615
+ const [first] = bashFindings(command, segments);
1616
+ if (first !== void 0) return { kind: "block", reason: bashMessage(input.t, first) };
1617
+ const found = scanText(command);
1618
+ if (found.length > 0) {
1619
+ const items = found.map((finding) => ({ ruleId: finding.ruleId, where: lineWhere(input.t, finding) }));
1620
+ return { kind: "block", reason: input.t("secrets.block.bash", { found: describeFindings(input.t, items) }) };
1621
+ }
1622
+ const commit = commitIntent(segments);
1623
+ return commit === void 0 ? ALLOW : commitDecision(input, commit.includeUnstaged);
1624
+ };
1625
+ var readDecision = (input) => {
1626
+ const inputs = toolInput(input.payload);
1627
+ const target = optionalString(inputs, "file_path") ?? optionalString(inputs, "path") ?? "";
1628
+ if (target === "" || !isSensitivePath(target)) return ALLOW;
1629
+ return { kind: "block", reason: input.t("secrets.block.read", { target: relativeFile(input.cwd, target) }) };
1630
+ };
1631
+ var WRITERS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
1632
+ var SHELLS = /* @__PURE__ */ new Set(["Bash", "PowerShell"]);
1633
+ var READS = /* @__PURE__ */ new Set(["Read", "Grep"]);
1634
+ var secretsPreToolUse = (input) => {
1635
+ const tool = toolName(input.payload);
1636
+ if (WRITERS.has(tool)) return writeDecision(input, tool);
1637
+ if (SHELLS.has(tool)) return bashDecision(input);
1638
+ if (READS.has(tool)) return readDecision(input);
1639
+ return ALLOW;
1640
+ };
1641
+
507
1642
  // src/hooks/handlers.ts
508
1643
  var allow = () => ({ kind: "allow" });
509
- var DEFAULT_HANDLERS = {};
1644
+ var DEFAULT_HANDLERS = {
1645
+ PreToolUse: [secretsPreToolUse],
1646
+ UserPromptSubmit: [secretsUserPromptSubmit],
1647
+ PostToolUse: [secretsPostToolUse]
1648
+ };
510
1649
 
511
1650
  // src/hooks/payload.ts
512
1651
  var EMPTY_PAYLOAD = {
@@ -516,9 +1655,9 @@ var EMPTY_PAYLOAD = {
516
1655
  stopHookActive: false,
517
1656
  raw: {}
518
1657
  };
519
- var parseHookPayload = (event, text, interactive) => {
520
- if (interactive && text.trim() === "") return accept(EMPTY_PAYLOAD);
521
- const parsed = parseJsonObject(text);
1658
+ var parseHookPayload = (event, text2, interactive) => {
1659
+ if (interactive && text2.trim() === "") return accept(EMPTY_PAYLOAD);
1660
+ const parsed = parseJsonObject(text2);
522
1661
  if (!parsed.ok) return reject(`stdin: ${parsed.error}`);
523
1662
  const payload = {
524
1663
  hookEventName: optionalString(parsed.value, "hook_event_name"),
@@ -574,7 +1713,7 @@ var withDeadline = (work, ms, startTimer) => new Promise((resolve2, reject2) =>
574
1713
  });
575
1714
 
576
1715
  // src/hooks/runner.ts
577
- var messageOf = (error) => error instanceof Error ? error.message : String(error);
1716
+ var messageOf2 = (error) => error instanceof Error ? error.message : String(error);
578
1717
  var decide = async (input, handlers) => {
579
1718
  let output = {};
580
1719
  for (const handler of handlers[input.event] ?? [allow]) {
@@ -585,14 +1724,21 @@ var decide = async (input, handlers) => {
585
1724
  return { kind: "allow", output };
586
1725
  };
587
1726
  var evaluate = async (event, options) => {
588
- const text = await readAll(options.stdin);
589
- const parsed = parseHookPayload(event, text, options.stdin.isTTY === true);
1727
+ const text2 = await readAll(options.stdin);
1728
+ const parsed = parseHookPayload(event, text2, options.stdin.isTTY === true);
590
1729
  if (!parsed.ok) return { kind: "failed", payload: void 0, detail: parsed.error };
591
1730
  const payload = parsed.value;
592
1731
  try {
593
- return { kind: "decided", payload, decision: await decide({ event, payload, cwd: options.cwd }, options.handlers) };
1732
+ return {
1733
+ kind: "decided",
1734
+ payload,
1735
+ decision: await decide(
1736
+ { event, payload, cwd: options.cwd, runtime: options.runtime, t: options.t },
1737
+ options.handlers
1738
+ )
1739
+ };
594
1740
  } catch (error) {
595
- return { kind: "failed", payload, detail: messageOf(error) };
1741
+ return { kind: "failed", payload, detail: messageOf2(error) };
596
1742
  }
597
1743
  };
598
1744
  var ALLOW_STDOUT = `${JSON.stringify({ continue: true })}
@@ -643,7 +1789,7 @@ var runHook = async (event, options) => {
643
1789
  options.t
644
1790
  );
645
1791
  } catch (error) {
646
- return failed(event, messageOf(error), options.t);
1792
+ return failed(event, messageOf2(error), options.t);
647
1793
  }
648
1794
  };
649
1795
 
@@ -670,7 +1816,8 @@ var hookCommand = {
670
1816
  handlers: DEFAULT_HANDLERS,
671
1817
  cwd: ctx.cwd,
672
1818
  t: ctx.t,
673
- startTimer: ctx.runtime.startTimer
1819
+ startTimer: ctx.runtime.startTimer,
1820
+ runtime: ctx.runtime
674
1821
  });
675
1822
  const data = { event, verdict: outcome.verdict };
676
1823
  const output = { stdout: outcome.stdout, stderr: outcome.stderr };
@@ -687,7 +1834,7 @@ var hookCommand = {
687
1834
  };
688
1835
 
689
1836
  // src/commands/i18n-check.ts
690
- import { join } from "node:path";
1837
+ import { join as join5 } from "node:path";
691
1838
 
692
1839
  // src/i18n/en.json
693
1840
  var en_default = {
@@ -772,7 +1919,52 @@ var en_default = {
772
1919
  "exit.internal": "5 internal error: Finite Monkey itself failed; please report it",
773
1920
  "help.exit_codes": "Exit codes",
774
1921
  "hook.stop_released": "the {event} hook would block again ({detail}), but Claude Code is already continuing from a stop hook, so it lets the session stop. Run `finitemonkey doctor`",
775
- "error.needs_confirmation": '"{command}" needs your decision before it changes anything: {question}'
1922
+ "error.needs_confirmation": '"{command}" needs your decision before it changes anything: {question}',
1923
+ "secrets.command.scan": "Find secrets in files, the staged changes or the git history (local only)",
1924
+ "secrets.command.accept": "Accept one finding into the baseline, with a reason and an expiry (the person only)",
1925
+ "secrets.command.incident": "List secret incidents, mark them rotated or closed, and show the history purge steps",
1926
+ "secrets.found_item": "{rule} at {where}",
1927
+ "secrets.found_more": "and {count} more",
1928
+ "secrets.where.line": "line {line}, column {column}",
1929
+ "secrets.where.file": "{file}:{line}:{column}",
1930
+ "secrets.where.new_text": "{file} (line {line} of the new text)",
1931
+ "secrets.block.write": "blocked: the new text for {file} has a credential ({found}). Nothing was written. Keep the value in .env (outside git) and read it by its variable name. If the key is real, treat it as exposed and rotate it.",
1932
+ "secrets.block.bash": "blocked: this command has a credential ({found}) and did not run. Read the value from an environment variable instead of writing it in the command. If the key is real, treat it as exposed and rotate it.",
1933
+ "secrets.block.commit": "blocked: the staged changes have a credential ({found}), so the commit did not run. Remove it from the staged files, keep it in .env (outside git) and rotate it if it is real.",
1934
+ "secrets.block.read": "blocked: {target} holds secrets, and reading it would put their values in the conversation. Ask the person for the variable names, or read .env.example.",
1935
+ "secrets.block.env_dump": 'blocked: `{command}` prints every environment variable, secrets included. Print only the variable you need by name, or check that it exists with `test -n "$NAME"`.',
1936
+ "secrets.block.person_only": "blocked: `{command}` is a decision for the person, not the model. Ask the person to run it in their own terminal.",
1937
+ "secrets.block.prompt": "your message was not sent to the model: it has a credential ({found}). If this key was pasted anywhere else, treat it as exposed and rotate it. To use it, put it in .env and tell me only the variable name. Never paste keys into the chat. Incident: {incident}.",
1938
+ "secrets.post.context": "Finite Monkey: the {tool} output contained a credential ({found}). Incident {incident} is open; rotating the key is the person's decision. Finite Monkey output never repeats the value.",
1939
+ "secrets.incident.unsaved": "not recorded ({detail})",
1940
+ "secrets.scan.title": "Secrets scan ({mode}): {total} found, {new} new, {known} known, {expired} expired, {fixed} fixed",
1941
+ "secrets.scan.item": " [{status}] {rule} {where} {mask} id {id}",
1942
+ "secrets.scan.clean": "No secrets found. The monkey keeps its bananas to itself.",
1943
+ "secrets.accept.done": "Accepted {id} ({rule} at {where}) as {state} until {expires}, by {owner}.",
1944
+ "secrets.action.accept": "accept a finding",
1945
+ "secrets.action.rotated": "mark an incident as rotated",
1946
+ "secrets.action.close": "close an incident",
1947
+ "secrets.incident.title": "{count} incident(s)",
1948
+ "secrets.incident.item": " {id} {state} {rule} {where} since {created}",
1949
+ "secrets.incident.none": "No secret incidents.",
1950
+ "secrets.incident.marked": "Incident {id} is now {state}.",
1951
+ "secrets.purge.title": "History purge for {id} ({where}, first commit {commit}). You run these steps; Finite Monkey never runs them:",
1952
+ "secrets.purge.step1": "1. Confirm the old key is revoked at the provider and the new one works in every consumer.",
1953
+ "secrets.purge.step2": "2. Outside the repository, write a replacements file with one line per leaked value: <value>==>REMOVED. Copy the value from your own records; Finite Monkey never stores or prints it.",
1954
+ "secrets.purge.step3": "3. In a fresh mirror clone, run: git filter-repo --replace-text <replacements file>",
1955
+ "secrets.purge.step4": "4. Relax branch protection yourself, push the rewritten refs, restore the protection and tell everyone with a clone to clone again.",
1956
+ "secrets.purge.step5": "5. Close the incident: finitemonkey secrets incident close {id}",
1957
+ "secrets.error.found": "{count} secret finding(s) outside the baseline or with an expired acceptance",
1958
+ "secrets.error.one_mode": "Choose only one of --staged, --history and --path",
1959
+ "secrets.error.git_unavailable": "The scan could not read git ({detail})",
1960
+ "secrets.error.baseline_invalid": "The baseline {file} is invalid: {detail}",
1961
+ "secrets.error.person_only": "Only the person can {action}, and this does not look like their terminal (no TTY, or running inside Claude Code)",
1962
+ "secrets.error.reason_required": "--reason is required: say why this finding is acceptable",
1963
+ "secrets.error.finding_not_found": "No current finding has the id {id}",
1964
+ "secrets.error.incident_not_found": "No incident has the id {id}",
1965
+ "secrets.error.not_rotated": "Incident {id} is {state}: rotate the key at the provider first, then mark it rotated",
1966
+ "secrets.error.bad_transition": "Incident {id} cannot go from {from} to {to}",
1967
+ "secrets.error.write_failed": "Could not write {file}: {detail}"
776
1968
  };
777
1969
 
778
1970
  // src/i18n/es.json
@@ -858,7 +2050,52 @@ var es_default = {
858
2050
  "exit.internal": "5 error interno: el propio Finite Monkey falló; por favor, repórtalo",
859
2051
  "help.exit_codes": "Códigos de salida",
860
2052
  "hook.stop_released": "el hook {event} bloquearía otra vez ({detail}), pero Claude Code ya está continuando por un hook de parada, así que deja que la sesión termine. Ejecuta `finitemonkey doctor`",
861
- "error.needs_confirmation": '"{command}" necesita tu decisión antes de cambiar algo: {question}'
2053
+ "error.needs_confirmation": '"{command}" necesita tu decisión antes de cambiar algo: {question}',
2054
+ "secrets.command.scan": "Busca secretos en los archivos, en los cambios preparados o en el historial de git (solo local)",
2055
+ "secrets.command.accept": "Acepta un hallazgo en el baseline, con motivo y vencimiento (solo la persona)",
2056
+ "secrets.command.incident": "Lista incidentes de secretos, los marca como rotados o cerrados y muestra los pasos para limpiar el historial",
2057
+ "secrets.found_item": "{rule} en {where}",
2058
+ "secrets.found_more": "y {count} más",
2059
+ "secrets.where.line": "línea {line}, columna {column}",
2060
+ "secrets.where.file": "{file}:{line}:{column}",
2061
+ "secrets.where.new_text": "{file} (línea {line} del texto nuevo)",
2062
+ "secrets.block.write": "bloqueado: el texto nuevo de {file} tiene una credencial ({found}). No se escribió nada. Deja el valor en .env (fuera de git) y léelo por el nombre de la variable. Si la clave es real, trátala como expuesta y rótala.",
2063
+ "secrets.block.bash": "bloqueado: este comando tiene una credencial ({found}) y no se ejecutó. Lee el valor de una variable de entorno en lugar de escribirlo en el comando. Si la clave es real, trátala como expuesta y rótala.",
2064
+ "secrets.block.commit": "bloqueado: los cambios preparados tienen una credencial ({found}), así que el commit no se ejecutó. Quítala de los archivos preparados, déjala en .env (fuera de git) y rótala si es real.",
2065
+ "secrets.block.read": "bloqueado: {target} guarda secretos, y leerlo pondría sus valores en la conversación. Pide a la persona los nombres de las variables, o lee .env.example.",
2066
+ "secrets.block.env_dump": 'bloqueado: `{command}` imprime todas las variables de entorno, secretos incluidos. Imprime solo la variable que necesitas por su nombre, o comprueba que existe con `test -n "$NAME"`.',
2067
+ "secrets.block.person_only": "bloqueado: `{command}` es una decisión de la persona, no del modelo. Pide a la persona que lo ejecute en su propia terminal.",
2068
+ "secrets.block.prompt": "tu mensaje no se envió al modelo: tiene una credencial ({found}). Si esta clave se pegó en otro lugar, trátala como expuesta y rótala. Para usarla, ponla en .env y dime solo el nombre de la variable. Nunca pegues claves en el chat. Incidente: {incident}.",
2069
+ "secrets.post.context": "Finite Monkey: la salida de {tool} contenía una credencial ({found}). El incidente {incident} está abierto; rotar la clave es decisión de la persona. La salida de Finite Monkey nunca repite el valor.",
2070
+ "secrets.incident.unsaved": "no registrado ({detail})",
2071
+ "secrets.scan.title": "Búsqueda de secretos ({mode}): {total} hallados, {new} nuevos, {known} conocidos, {expired} vencidos, {fixed} corregidos",
2072
+ "secrets.scan.item": " [{status}] {rule} {where} {mask} id {id}",
2073
+ "secrets.scan.clean": "No se encontraron secretos. El mono se guarda sus bananas.",
2074
+ "secrets.accept.done": "Aceptado {id} ({rule} en {where}) como {state} hasta {expires}, por {owner}.",
2075
+ "secrets.action.accept": "aceptar un hallazgo",
2076
+ "secrets.action.rotated": "marcar un incidente como rotado",
2077
+ "secrets.action.close": "cerrar un incidente",
2078
+ "secrets.incident.title": "{count} incidente(s)",
2079
+ "secrets.incident.item": " {id} {state} {rule} {where} desde {created}",
2080
+ "secrets.incident.none": "No hay incidentes de secretos.",
2081
+ "secrets.incident.marked": "El incidente {id} ahora está {state}.",
2082
+ "secrets.purge.title": "Limpieza del historial de {id} ({where}, primer commit {commit}). Estos pasos los ejecutas tú; Finite Monkey nunca los ejecuta:",
2083
+ "secrets.purge.step1": "1. Confirma que la clave vieja está revocada en el proveedor y que la nueva funciona en todos los consumidores.",
2084
+ "secrets.purge.step2": "2. Fuera del repositorio, escribe un archivo de reemplazos con una línea por valor filtrado: <valor>==>REMOVED. Copia el valor de tus propios registros; Finite Monkey nunca lo guarda ni lo imprime.",
2085
+ "secrets.purge.step3": "3. En un clon espejo nuevo, ejecuta: git filter-repo --replace-text <archivo de reemplazos>",
2086
+ "secrets.purge.step4": "4. Relaja tú la protección de la rama, envía las refs reescritas, restaura la protección y avisa a quien tenga un clon que vuelva a clonar.",
2087
+ "secrets.purge.step5": "5. Cierra el incidente: finitemonkey secrets incident close {id}",
2088
+ "secrets.error.found": "{count} hallazgo(s) de secretos fuera del baseline o con aceptación vencida",
2089
+ "secrets.error.one_mode": "Elige solo uno entre --staged, --history y --path",
2090
+ "secrets.error.git_unavailable": "La búsqueda no pudo leer git ({detail})",
2091
+ "secrets.error.baseline_invalid": "El baseline {file} no es válido: {detail}",
2092
+ "secrets.error.person_only": "Solo la persona puede {action}, y esto no parece su terminal (sin TTY, o dentro de Claude Code)",
2093
+ "secrets.error.reason_required": "--reason es obligatorio: di por qué este hallazgo es aceptable",
2094
+ "secrets.error.finding_not_found": "Ningún hallazgo actual tiene el id {id}",
2095
+ "secrets.error.incident_not_found": "Ningún incidente tiene el id {id}",
2096
+ "secrets.error.not_rotated": "El incidente {id} está {state}: rota la clave en el proveedor primero y luego márcalo como rotado",
2097
+ "secrets.error.bad_transition": "El incidente {id} no puede pasar de {from} a {to}",
2098
+ "secrets.error.write_failed": "No se pudo escribir {file}: {detail}"
862
2099
  };
863
2100
 
864
2101
  // src/i18n/pt-BR.json
@@ -944,7 +2181,52 @@ var pt_BR_default = {
944
2181
  "exit.internal": "5 erro interno: o próprio Finite Monkey falhou; por favor, reporte",
945
2182
  "help.exit_codes": "Códigos de saída",
946
2183
  "hook.stop_released": "o hook {event} bloquearia de novo ({detail}), mas o Claude Code já está continuando por causa de um hook de parada, então ele deixa a sessão parar. Rode `finitemonkey doctor`",
947
- "error.needs_confirmation": '"{command}" precisa da sua decisão antes de mudar qualquer coisa: {question}'
2184
+ "error.needs_confirmation": '"{command}" precisa da sua decisão antes de mudar qualquer coisa: {question}',
2185
+ "secrets.command.scan": "Procura segredos nos arquivos, nas mudanças preparadas ou no histórico do git (só local)",
2186
+ "secrets.command.accept": "Aceita um achado no baseline, com motivo e validade (só a pessoa)",
2187
+ "secrets.command.incident": "Lista incidentes de segredo, marca como rotacionado ou fechado e mostra os passos de limpeza do histórico",
2188
+ "secrets.found_item": "{rule} em {where}",
2189
+ "secrets.found_more": "e mais {count}",
2190
+ "secrets.where.line": "linha {line}, coluna {column}",
2191
+ "secrets.where.file": "{file}:{line}:{column}",
2192
+ "secrets.where.new_text": "{file} (linha {line} do texto novo)",
2193
+ "secrets.block.write": "bloqueado: o texto novo de {file} tem uma credencial ({found}). Nada foi gravado. Deixe o valor no .env (fora do git) e leia pelo nome da variável. Se a chave é real, trate como exposta e troque.",
2194
+ "secrets.block.bash": "bloqueado: este comando tem uma credencial ({found}) e não rodou. Leia o valor de uma variável de ambiente em vez de escrevê-lo no comando. Se a chave é real, trate como exposta e troque.",
2195
+ "secrets.block.commit": "bloqueado: as mudanças preparadas têm uma credencial ({found}), então o commit não rodou. Tire-a dos arquivos preparados, deixe no .env (fora do git) e troque a chave se for real.",
2196
+ "secrets.block.read": "bloqueado: {target} guarda segredos, e lê-lo colocaria os valores na conversa. Peça à pessoa os nomes das variáveis, ou leia o .env.example.",
2197
+ "secrets.block.env_dump": 'bloqueado: `{command}` imprime todas as variáveis de ambiente, segredos inclusive. Imprima só a variável que precisa, pelo nome, ou confira que ela existe com `test -n "$NAME"`.',
2198
+ "secrets.block.person_only": "bloqueado: `{command}` é decisão da pessoa, não do modelo. Peça à pessoa que rode no terminal dela.",
2199
+ "secrets.block.prompt": "sua mensagem não foi enviada ao modelo: ela tem uma credencial ({found}). Se essa chave foi colada em outro lugar, trate como exposta e troque. Para usá-la, ponha no .env e me diga só o nome da variável. Nunca cole chaves na conversa. Incidente: {incident}.",
2200
+ "secrets.post.context": "Finite Monkey: a saída de {tool} continha uma credencial ({found}). O incidente {incident} está aberto; trocar a chave é decisão da pessoa. A saída do Finite Monkey nunca repete o valor.",
2201
+ "secrets.incident.unsaved": "não registrado ({detail})",
2202
+ "secrets.scan.title": "Varredura de segredos ({mode}): {total} achados, {new} novos, {known} conhecidos, {expired} vencidos, {fixed} corrigidos",
2203
+ "secrets.scan.item": " [{status}] {rule} {where} {mask} id {id}",
2204
+ "secrets.scan.clean": "Nenhum segredo encontrado. O macaco guarda as bananas para si.",
2205
+ "secrets.accept.done": "Aceito {id} ({rule} em {where}) como {state} até {expires}, por {owner}.",
2206
+ "secrets.action.accept": "aceitar um achado",
2207
+ "secrets.action.rotated": "marcar um incidente como rotacionado",
2208
+ "secrets.action.close": "fechar um incidente",
2209
+ "secrets.incident.title": "{count} incidente(s)",
2210
+ "secrets.incident.item": " {id} {state} {rule} {where} desde {created}",
2211
+ "secrets.incident.none": "Nenhum incidente de segredo.",
2212
+ "secrets.incident.marked": "O incidente {id} agora está {state}.",
2213
+ "secrets.purge.title": "Limpeza do histórico de {id} ({where}, primeiro commit {commit}). Quem roda estes passos é você; o Finite Monkey nunca os roda:",
2214
+ "secrets.purge.step1": "1. Confirme que a chave antiga foi revogada no provedor e que a nova funciona em todos os consumidores.",
2215
+ "secrets.purge.step2": "2. Fora do repositório, escreva um arquivo de substituições com uma linha por valor vazado: <valor>==>REMOVED. Copie o valor dos seus registros; o Finite Monkey nunca o guarda nem imprime.",
2216
+ "secrets.purge.step3": "3. Num clone espelho novo, rode: git filter-repo --replace-text <arquivo de substituições>",
2217
+ "secrets.purge.step4": "4. Afrouxe a proteção da branch você mesmo, envie as refs reescritas, restaure a proteção e avise quem tem clone para clonar de novo.",
2218
+ "secrets.purge.step5": "5. Feche o incidente: finitemonkey secrets incident close {id}",
2219
+ "secrets.error.found": "{count} achado(s) de segredo fora do baseline ou com aceite vencido",
2220
+ "secrets.error.one_mode": "Escolha só um entre --staged, --history e --path",
2221
+ "secrets.error.git_unavailable": "A varredura não conseguiu ler o git ({detail})",
2222
+ "secrets.error.baseline_invalid": "O baseline {file} é inválido: {detail}",
2223
+ "secrets.error.person_only": "Só a pessoa pode {action}, e isto não parece o terminal dela (sem TTY, ou rodando dentro do Claude Code)",
2224
+ "secrets.error.reason_required": "--reason é obrigatório: diga por que este achado é aceitável",
2225
+ "secrets.error.finding_not_found": "Nenhum achado atual tem o id {id}",
2226
+ "secrets.error.incident_not_found": "Nenhum incidente tem o id {id}",
2227
+ "secrets.error.not_rotated": "O incidente {id} está {state}: troque a chave no provedor primeiro e depois marque como rotacionado",
2228
+ "secrets.error.bad_transition": "O incidente {id} não pode ir de {from} para {to}",
2229
+ "secrets.error.write_failed": "Não foi possível gravar {file}: {detail}"
948
2230
  };
949
2231
 
950
2232
  // src/i18n/catalogs.ts
@@ -1022,10 +2304,10 @@ var unreadable = (dir, file, detail) => ({
1022
2304
  exitCode: ExitCode.NotConfigured
1023
2305
  });
1024
2306
  var readCatalog = (ctx, dir, lang) => {
1025
- const file = toAbsolutePath(join(dir, `${lang}.json`));
1026
- const text = ctx.runtime.readText(file);
1027
- if (!text.ok) return reject(unreadable(dir, file, text.error));
1028
- const parsed = parseJsonObject(text.value);
2307
+ const file = toAbsolutePath(join5(dir, `${lang}.json`));
2308
+ const text2 = ctx.runtime.readText(file);
2309
+ if (!text2.ok) return reject(unreadable(dir, file, text2.error));
2310
+ const parsed = parseJsonObject(text2.value);
1029
2311
  return parsed.ok ? parsed : reject(unreadable(dir, file, parsed.error));
1030
2312
  };
1031
2313
  var loadCatalogs = (ctx, dir) => {
@@ -1104,6 +2386,382 @@ var uninstallCommand = planned("uninstall", "command.uninstall.summary", {
1104
2386
  "keep-generated": { type: "boolean" }
1105
2387
  });
1106
2388
 
2389
+ // src/secrets/project.ts
2390
+ var projectRoot = (ctx) => {
2391
+ const top = ctx.runtime.git(["rev-parse", "--show-toplevel"], ctx.cwd);
2392
+ const path = top.stdout.trim();
2393
+ return top.ok && path !== "" ? toAbsolutePath(path) : ctx.cwd;
2394
+ };
2395
+ var isPerson = (ctx) => ctx.io.stdin.isTTY === true && ctx.env["CLAUDECODE"] === void 0 && ctx.env["CLAUDE_CODE_ENTRYPOINT"] === void 0;
2396
+ var personName = (ctx) => {
2397
+ const name = ctx.runtime.git(["config", "user.name"], ctx.cwd).stdout.trim();
2398
+ return name !== "" ? name : ctx.env["USER"] ?? ctx.env["USERNAME"] ?? "person";
2399
+ };
2400
+
2401
+ // src/commands/secrets-problems.ts
2402
+ var baselineInvalid = (detail) => ({
2403
+ code: "secrets_baseline_invalid",
2404
+ messageKey: "secrets.error.baseline_invalid",
2405
+ params: { file: BASELINE_FILE, detail },
2406
+ fix: `git checkout -- ${BASELINE_FILE}`,
2407
+ exitCode: ExitCode.NotConfigured
2408
+ });
2409
+ var writeFailed = (file, detail) => ({
2410
+ code: "secrets_write_failed",
2411
+ messageKey: "secrets.error.write_failed",
2412
+ params: { file, detail },
2413
+ fix: "finitemonkey doctor",
2414
+ exitCode: ExitCode.Internal
2415
+ });
2416
+ var gitUnavailable = (detail) => ({
2417
+ code: "secrets_git_unavailable",
2418
+ messageKey: "secrets.error.git_unavailable",
2419
+ params: { detail },
2420
+ fix: "finitemonkey doctor",
2421
+ exitCode: ExitCode.NotConfigured
2422
+ });
2423
+
2424
+ // src/commands/secrets-accept.ts
2425
+ var DAY_MS = 864e5;
2426
+ var daysOf = (values) => {
2427
+ const raw = stringOption(values, "days");
2428
+ if (raw === void 0) return EXCEPTION_MAX_DAYS;
2429
+ const days = Number.parseInt(raw, 10);
2430
+ return Number.isInteger(days) && days > 0 && days <= EXCEPTION_MAX_DAYS ? days : void 0;
2431
+ };
2432
+ var plusDays = (iso, days) => new Date(Date.parse(iso) + days * DAY_MS).toISOString();
2433
+ var failure = (ctx, problem) => failureFrom(ctx.t, problem);
2434
+ var inputProblem = (ctx, id, reason, days) => {
2435
+ const fix = "finitemonkey secrets accept <id> --reason <why> [--days 1-90] [--false-positive]";
2436
+ if (!isPerson(ctx)) {
2437
+ return needsPersonProblem("secrets.error.person_only", fix, { action: ctx.t("secrets.action.accept") });
2438
+ }
2439
+ if (id === "") return usageProblem("invalid_arguments", "error.invalid_arguments", fix, { detail: "<id>" });
2440
+ if (reason === void 0 || reason.trim() === "") {
2441
+ return usageProblem("invalid_arguments", "secrets.error.reason_required", fix);
2442
+ }
2443
+ if (days === void 0) {
2444
+ return usageProblem("invalid_arguments", "error.invalid_arguments", fix, {
2445
+ detail: `--days 1-${String(EXCEPTION_MAX_DAYS)}`
2446
+ });
2447
+ }
2448
+ return void 0;
2449
+ };
2450
+ var entryOf = (ctx, finding, args) => {
2451
+ const now = ctx.runtime.now();
2452
+ return {
2453
+ id: finding.id,
2454
+ rule: finding.ruleId,
2455
+ file: finding.file,
2456
+ line: finding.line,
2457
+ fingerprint: finding.fingerprint,
2458
+ state: args.falsePositive ? "falso-positivo" : "aceito",
2459
+ owner: personName(ctx),
2460
+ reason: args.reason,
2461
+ acceptedAt: now,
2462
+ expiresAt: plusDays(now, args.days)
2463
+ };
2464
+ };
2465
+ var acceptEntry = (ctx, args) => {
2466
+ const root = projectRoot(ctx);
2467
+ const baseline = loadBaseline(ctx.runtime, root);
2468
+ if (!baseline.ok) {
2469
+ return failure(ctx, baselineInvalid(baseline.error));
2470
+ }
2471
+ const scanned = scanPath(ctx.runtime, root, ".");
2472
+ const finding = scanned.ok ? scanned.value.find((item) => item.id === args.id) : void 0;
2473
+ if (finding === void 0) {
2474
+ return failure(
2475
+ ctx,
2476
+ usageProblem("secrets_finding_not_found", "secrets.error.finding_not_found", "finitemonkey secrets scan", {
2477
+ id: args.id
2478
+ })
2479
+ );
2480
+ }
2481
+ const entry = entryOf(ctx, finding, args);
2482
+ const entries = [...baseline.value.entries.filter((item) => item.id !== entry.id), entry];
2483
+ const saved = saveBaseline(ctx.runtime, root, { schema: baseline.value.schema, entries });
2484
+ if (!saved.ok) {
2485
+ return failure(ctx, writeFailed(BASELINE_FILE, saved.error));
2486
+ }
2487
+ const line = ctx.t("secrets.accept.done", {
2488
+ id: entry.id,
2489
+ rule: entry.rule,
2490
+ where: whereOf(finding),
2491
+ state: entry.state,
2492
+ expires: entry.expiresAt.slice(0, 10),
2493
+ owner: entry.owner
2494
+ });
2495
+ return success(entry, [line]);
2496
+ };
2497
+ var secretsAcceptCommand = {
2498
+ path: ["secrets", "accept"],
2499
+ summaryKey: "secrets.command.accept",
2500
+ usage: "finitemonkey secrets accept <finding-id> --reason <why> [--days 1-90] [--false-positive]",
2501
+ status: "ready",
2502
+ maxPositionals: 1,
2503
+ options: { reason: { type: "string" }, days: { type: "string" }, "false-positive": { type: "boolean" } },
2504
+ run: (ctx, args) => {
2505
+ const id = args.positionals[0] ?? "";
2506
+ const reason = stringOption(args.values, "reason");
2507
+ const days = daysOf(args.values);
2508
+ const problem = inputProblem(ctx, id, reason, days);
2509
+ if (problem !== void 0 || reason === void 0 || days === void 0) {
2510
+ return Promise.resolve(
2511
+ failure(
2512
+ ctx,
2513
+ problem ?? usageProblem("invalid_arguments", "secrets.error.reason_required", "finitemonkey help secrets accept")
2514
+ )
2515
+ );
2516
+ }
2517
+ return Promise.resolve(
2518
+ acceptEntry(ctx, { id, reason, days, falsePositive: args.values["false-positive"] === true })
2519
+ );
2520
+ }
2521
+ };
2522
+
2523
+ // src/commands/secrets-incident.ts
2524
+ var INCIDENT_ACTIONS = ["list", "rotated", "close", "purge"];
2525
+ var USAGE = "finitemonkey secrets incident [list | rotated <id> | close <id> | purge <id>]";
2526
+ var TARGET = { rotated: "rotacionado", close: "fechado" };
2527
+ var PURGE_STEPS = [
2528
+ "secrets.purge.step1",
2529
+ "secrets.purge.step2",
2530
+ "secrets.purge.step3",
2531
+ "secrets.purge.step4",
2532
+ "secrets.purge.step5"
2533
+ ];
2534
+ var fail = (ctx, problem) => failureFrom(ctx.t, problem);
2535
+ var notFound2 = (ctx, id) => fail(
2536
+ ctx,
2537
+ usageProblem("secrets_incident_not_found", "secrets.error.incident_not_found", `finitemonkey secrets incident`, {
2538
+ id
2539
+ })
2540
+ );
2541
+ var itemLine = (ctx, incident) => ctx.t("secrets.incident.item", {
2542
+ id: incident.id,
2543
+ state: incident.state,
2544
+ rule: incident.rule,
2545
+ where: incident.where,
2546
+ created: incident.createdAt.slice(0, 10)
2547
+ });
2548
+ var list = (ctx, root) => {
2549
+ const incidents = listIncidents(ctx.runtime, root);
2550
+ const human = incidents.length === 0 ? [ctx.t("secrets.incident.none")] : [ctx.t("secrets.incident.title", { count: incidents.length }), ...incidents.map((item) => itemLine(ctx, item))];
2551
+ return success({ schema: "finitemonkey/secrets-incidents@1", incidents }, human);
2552
+ };
2553
+ var notRotated = (ctx, incident) => fail(ctx, {
2554
+ code: "secrets_not_rotated",
2555
+ messageKey: "secrets.error.not_rotated",
2556
+ params: { id: incident.id, state: incident.state },
2557
+ fix: `finitemonkey secrets incident rotated ${incident.id}`,
2558
+ exitCode: ExitCode.NeedsPerson
2559
+ });
2560
+ var move = (ctx, root, incident, action) => {
2561
+ const to = TARGET[action];
2562
+ if (!isPerson(ctx)) {
2563
+ return fail(
2564
+ ctx,
2565
+ needsPersonProblem("secrets.error.person_only", `finitemonkey secrets incident ${action} ${incident.id}`, {
2566
+ action: ctx.t(`secrets.action.${action}`)
2567
+ })
2568
+ );
2569
+ }
2570
+ if (!canMove(incident.state, to))
2571
+ return action === "close" ? notRotated(ctx, incident) : fail(
2572
+ ctx,
2573
+ usageProblem("invalid_arguments", "secrets.error.bad_transition", USAGE, {
2574
+ id: incident.id,
2575
+ from: incident.state,
2576
+ to
2577
+ })
2578
+ );
2579
+ const moved = moveIncident(incident, to, ctx.runtime.now(), personName(ctx));
2580
+ const saved = saveIncident(ctx.runtime, root, moved);
2581
+ if (!saved.ok) {
2582
+ return fail(ctx, writeFailed(incident.id, saved.error));
2583
+ }
2584
+ return success(moved, [ctx.t("secrets.incident.marked", { id: moved.id, state: moved.state })]);
2585
+ };
2586
+ var purge = (ctx, incident) => {
2587
+ if (!isRotated(incident)) return notRotated(ctx, incident);
2588
+ const params = { id: incident.id, where: incident.where, commit: incident.firstCommit ?? "?" };
2589
+ const steps = PURGE_STEPS.map((key) => ctx.t(key, params));
2590
+ return success({ id: incident.id, state: incident.state, steps }, [ctx.t("secrets.purge.title", params), ...steps]);
2591
+ };
2592
+ var actionOf = (raw) => INCIDENT_ACTIONS.find((action) => action === (raw ?? "list"));
2593
+ var secretsIncidentCommand = {
2594
+ path: ["secrets", "incident"],
2595
+ summaryKey: "secrets.command.incident",
2596
+ usage: USAGE,
2597
+ status: "ready",
2598
+ maxPositionals: 2,
2599
+ run: (ctx, args) => {
2600
+ const action = actionOf(args.positionals[0]);
2601
+ const root = projectRoot(ctx);
2602
+ if (action === void 0)
2603
+ return Promise.resolve(
2604
+ fail(
2605
+ ctx,
2606
+ usageProblem("invalid_arguments", "error.invalid_arguments", USAGE, { detail: args.positionals[0] ?? "" })
2607
+ )
2608
+ );
2609
+ if (action === "list") return Promise.resolve(list(ctx, root));
2610
+ const id = args.positionals[1] ?? "";
2611
+ const incident = readIncident(ctx.runtime, root, id);
2612
+ if (!incident.ok) return Promise.resolve(notFound2(ctx, id));
2613
+ return Promise.resolve(action === "purge" ? purge(ctx, incident.value) : move(ctx, root, incident.value, action));
2614
+ }
2615
+ };
2616
+
2617
+ // src/mask/mask.ts
2618
+ var maskToken = (finding) => `«secret:${finding.ruleId}:${finding.tail}»`;
2619
+ var maskText = (text2) => {
2620
+ const findings = scanText(text2, { rules: CREDENTIAL_RULES });
2621
+ if (findings.length === 0) return text2;
2622
+ let out = "";
2623
+ let cursor = 0;
2624
+ for (const finding of findings) {
2625
+ out += text2.slice(cursor, finding.start) + maskToken(finding);
2626
+ cursor = finding.end;
2627
+ }
2628
+ return out + text2.slice(cursor);
2629
+ };
2630
+ var maskDeep = (value) => {
2631
+ if (typeof value === "string") return maskText(value);
2632
+ if (Array.isArray(value)) return value.map(maskDeep);
2633
+ if (typeof value === "object" && value !== null) {
2634
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [maskText(key), maskDeep(inner)]));
2635
+ }
2636
+ return value;
2637
+ };
2638
+
2639
+ // src/commands/secrets-scan.ts
2640
+ var SECRETS_SCAN_SCHEMA = "finitemonkey/secrets-scan@1";
2641
+ var view = (finding, status) => ({
2642
+ id: finding.id,
2643
+ rule: finding.ruleId,
2644
+ severity: finding.severity,
2645
+ file: finding.file,
2646
+ line: finding.line,
2647
+ column: finding.column,
2648
+ fingerprint: finding.fingerprint,
2649
+ mask: maskToken(finding),
2650
+ status,
2651
+ ...finding.commit === void 0 ? {} : { commit: finding.commit }
2652
+ });
2653
+ var modeOf = (values) => {
2654
+ const chosen = [values["staged"] === true, values["history"] === true, stringOption(values, "path") !== void 0];
2655
+ if (chosen.filter(Boolean).length > 1) return void 0;
2656
+ if (chosen[0] === true) return "staged";
2657
+ return chosen[1] === true ? "history" : "path";
2658
+ };
2659
+ var normalizedTarget = (target) => target.replace(/\\/g, "/").replace(/^\.\/?/, "").replace(/\/$/, "");
2660
+ var scopeOf = (target) => {
2661
+ const prefix = normalizedTarget(target);
2662
+ return (file) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`);
2663
+ };
2664
+ var runScan = async (ctx, root, mode, target) => {
2665
+ if (mode === "staged") return scanStaged(ctx.runtime, root);
2666
+ if (mode === "history") return scanHistory(ctx.runtime, root);
2667
+ return scanPath(ctx.runtime, root, normalizedTarget(target) || ".");
2668
+ };
2669
+ var historyIncidents = (ctx, root, fresh2) => fresh2.flatMap((finding) => {
2670
+ if (ruleById(finding.ruleId)?.kind !== "credential") return [];
2671
+ const draft = {
2672
+ rule: finding.ruleId,
2673
+ fingerprint: finding.fingerprint,
2674
+ where: whereOf(finding),
2675
+ source: "history",
2676
+ state: "aberto",
2677
+ ...finding.commit === void 0 ? {} : { firstCommit: finding.commit }
2678
+ };
2679
+ const saved = recordIncident(ctx.runtime, root, draft, HOOK_ACTOR);
2680
+ return saved.ok ? [saved.value.id] : [];
2681
+ });
2682
+ var dataOf = (mode, outcome, incidents) => {
2683
+ const findings = [
2684
+ ...outcome.fresh.map((finding) => view(finding, "new")),
2685
+ ...outcome.known.map((finding) => view(finding, "known")),
2686
+ ...outcome.expired.map((finding) => view(finding, "expired"))
2687
+ ];
2688
+ return {
2689
+ schema: SECRETS_SCAN_SCHEMA,
2690
+ mode,
2691
+ baseline: BASELINE_FILE,
2692
+ findings,
2693
+ fixed: outcome.fixed.map((entry) => entry.id),
2694
+ incidents,
2695
+ summary: {
2696
+ total: findings.length,
2697
+ new: outcome.fresh.length,
2698
+ known: outcome.known.length,
2699
+ expired: outcome.expired.length,
2700
+ fixed: outcome.fixed.length
2701
+ }
2702
+ };
2703
+ };
2704
+ var humanOf = (ctx, data) => [
2705
+ ctx.t("secrets.scan.title", { mode: data.mode, ...data.summary }),
2706
+ ...data.findings.map(
2707
+ (item) => ctx.t("secrets.scan.item", {
2708
+ status: item.status,
2709
+ rule: item.rule,
2710
+ where: `${item.file}:${String(item.line)}`,
2711
+ id: item.id,
2712
+ mask: item.mask
2713
+ })
2714
+ ),
2715
+ ...data.summary.total === 0 ? [ctx.t("secrets.scan.clean")] : []
2716
+ ];
2717
+ var resultOf = (ctx, data) => {
2718
+ const human = humanOf(ctx, data);
2719
+ const failing = data.summary.new + data.summary.expired;
2720
+ if (failing === 0) return success(data, human);
2721
+ return failureFrom(
2722
+ ctx.t,
2723
+ {
2724
+ code: "secrets_found",
2725
+ messageKey: "secrets.error.found",
2726
+ params: { count: failing },
2727
+ fix: "finitemonkey secrets accept <id> --reason <why>",
2728
+ exitCode: ExitCode.CheckFailed,
2729
+ data
2730
+ },
2731
+ human
2732
+ );
2733
+ };
2734
+ var prune = (ctx, root, baseline, outcome) => {
2735
+ if (outcome.fixed.length > 0) saveBaseline(ctx.runtime, root, withoutFixed(baseline, outcome.fixed));
2736
+ };
2737
+ var secretsScanCommand = {
2738
+ path: ["secrets", "scan"],
2739
+ summaryKey: "secrets.command.scan",
2740
+ usage: "finitemonkey secrets scan [--staged | --history | --path <dir>] [--json]",
2741
+ status: "ready",
2742
+ options: { staged: { type: "boolean" }, history: { type: "boolean" }, path: { type: "string" } },
2743
+ run: async (ctx, args) => {
2744
+ const mode = modeOf(args.values);
2745
+ const fix = "finitemonkey help secrets scan";
2746
+ if (mode === void 0) return failureFrom(ctx.t, usageProblem("invalid_arguments", "secrets.error.one_mode", fix));
2747
+ const root = projectRoot(ctx);
2748
+ const target = stringOption(args.values, "path") ?? ".";
2749
+ const scanned = await runScan(ctx, root, mode, target);
2750
+ if (!scanned.ok) return failureFrom(ctx.t, gitUnavailable(scanned.error));
2751
+ const baseline = loadBaseline(ctx.runtime, root);
2752
+ if (!baseline.ok) return failureFrom(ctx.t, baselineInvalid(baseline.error));
2753
+ const outcome = ratchet(
2754
+ scanned.value,
2755
+ baseline.value,
2756
+ ctx.runtime.now(),
2757
+ mode === "path" ? scopeOf(target) : void 0
2758
+ );
2759
+ if (mode === "path") prune(ctx, root, baseline.value, outcome);
2760
+ const incidents = mode === "history" ? historyIncidents(ctx, root, outcome.fresh) : [];
2761
+ return resultOf(ctx, dataOf(mode, outcome, incidents));
2762
+ }
2763
+ };
2764
+
1107
2765
  // src/commands/version.ts
1108
2766
  var versionCommand = {
1109
2767
  path: ["version"],
@@ -1124,6 +2782,9 @@ var REGISTRY = [
1124
2782
  uninstallCommand,
1125
2783
  i18nCheckCommand,
1126
2784
  hookCommand,
2785
+ secretsScanCommand,
2786
+ secretsAcceptCommand,
2787
+ secretsIncidentCommand,
1127
2788
  versionCommand,
1128
2789
  helpCommand
1129
2790
  ];
@@ -1205,23 +2866,26 @@ var resultFromThrown = (thrown, t) => {
1205
2866
  };
1206
2867
  var block = (items) => items.length > 0 ? `${items.join("\n")}
1207
2868
  ` : "";
2869
+ var write = (sink, text2) => {
2870
+ if (text2 !== "") sink.write(maskText(text2));
2871
+ };
1208
2872
  var render = (ctx, command, result) => {
1209
2873
  if (result.raw !== void 0) {
1210
- ctx.io.stdout.write(result.raw.stdout);
1211
- ctx.io.stderr.write(result.raw.stderr);
2874
+ write(ctx.io.stdout, result.raw.stdout);
2875
+ write(ctx.io.stderr, result.raw.stderr);
1212
2876
  return;
1213
2877
  }
1214
2878
  if (ctx.json) {
1215
- ctx.io.stdout.write(`${JSON.stringify(toEnvelope(command, result))}
2879
+ ctx.io.stdout.write(`${JSON.stringify(maskDeep(toEnvelope(command, result)))}
1216
2880
  `);
1217
2881
  return;
1218
2882
  }
1219
- ctx.io.stdout.write(block(result.human ?? []));
2883
+ write(ctx.io.stdout, block(result.human ?? []));
1220
2884
  if (!result.ok) {
1221
2885
  const { message, fix } = result.error;
1222
- ctx.io.stderr.write(block([ctx.t("output.error", { message }), ctx.t("output.fix", { fix })]));
2886
+ write(ctx.io.stderr, block([ctx.t("output.error", { message }), ctx.t("output.fix", { fix })]));
1223
2887
  }
1224
- if (result.next !== void 0) ctx.io.stdout.write(block([ctx.t("output.next", { next: result.next })]));
2888
+ if (result.next !== void 0) write(ctx.io.stdout, block([ctx.t("output.next", { next: result.next })]));
1225
2889
  };
1226
2890
 
1227
2891
  // src/core/run.ts
@@ -1268,9 +2932,9 @@ var run = async (options) => {
1268
2932
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1269
2933
  var UNKNOWN_VERSION = "0.0.0-unknown";
1270
2934
  var readPackageVersion = (readText, moduleUrl) => {
1271
- const text = readText(toAbsolutePath(fileURLToPath2(new URL("../package.json", moduleUrl))));
1272
- if (!text.ok) return UNKNOWN_VERSION;
1273
- const parsed = parseJson(text.value);
2935
+ const text2 = readText(toAbsolutePath(fileURLToPath2(new URL("../package.json", moduleUrl))));
2936
+ if (!text2.ok) return UNKNOWN_VERSION;
2937
+ const parsed = parseJson(text2.value);
1274
2938
  if (!parsed.ok || !isRecord(parsed.value)) return UNKNOWN_VERSION;
1275
2939
  const version = parsed.value["version"];
1276
2940
  return typeof version === "string" ? version : UNKNOWN_VERSION;