swarmdo 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -515,7 +515,7 @@ function getCostFromStdin() {
515
515
  // misses, the displayed version is meaningful (matches what the user
516
516
  // installed), not a stale hard-coded string.
517
517
  function getPkgVersion() {
518
- let ver = '1.8.0';
518
+ let ver = '1.10.0';
519
519
  try {
520
520
  const home = os.homedir();
521
521
  const pkgPaths = [
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![swarmdo](swarmdo/assets/swarmdo-banner.svg)](https://swarmdo.com)
4
4
 
5
- [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.8.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
5
+ [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.10.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
6
6
  [![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
7
7
  [![Website](https://img.shields.io/badge/swarmdo.com-e2a33c?style=for-the-badge&logoColor=black)](https://swarmdo.com)
8
8
  [![Star on GitHub](https://img.shields.io/github/stars/SwarmDo/swarmdo?style=for-the-badge&logo=github&color=gold)](https://github.com/SwarmDo/swarmdo)
@@ -278,6 +278,8 @@ The recent release train added a full day-to-day operations layer around the swa
278
278
  | `swarmdo transcript` (alias `tx`) | **Export any Claude Code session** to clean markdown — system noise stripped, ready to share; `transcript search <query>` full-text-searches every session |
279
279
  | `swarmdo compact` | **Compress noisy command output** before it reaches an LLM — strip ANSI, collapse repeats, fold `node_modules` stack frames, window long logs. `npm test 2>&1 \| swarmdo compact` or `swarmdo compact -- npm test` (exit code propagates). Deterministic, zero tokens |
280
280
  | `swarmdo codegraph` (alias `cg`) | **Queryable symbol index** — `codegraph index` scans TS/JS for exported symbols; `query <name>` (with `--fuzzy`/`--kind`) and `file <path>` answer "where is X defined / what does this file export" from `.swarm/codegraph.json` instead of grep+read round-trips. 1,750 symbols across 289 files in <1s. Also exposed as MCP tools (`codegraph_query`/`file`/`index`/`stats`) so agents query the index in-session |
281
+ | `swarmdo redact` | **Mask secrets before they reach an LLM/log/memory** — detect API keys, tokens, and private keys (gitleaks-style rule catalog + Shannon-entropy fallback) and redact them. Stdin filter (`cat deploy.log \| swarmdo redact`), command-wrap (`swarmdo redact -- npm run deploy`), or `--scan` to fail CI on any secret. Also MCP tools (`redact_text`/`redact_scan`). Deterministic, zero tokens |
282
+ | `swarmdo pack` | **Bundle a repo into one AI-context blob** — walk the tree (respects `.gitignore` + glob `--include`/`--exclude`, skips binaries/node_modules), emit markdown/xml/json/plain with a directory tree and per-file + total token estimates. `swarmdo pack --tokens` for a budget breakdown; `--redact` masks secrets first. Deterministic |
281
283
  | `swarmdo integrations` (alias `integrate`) | **Use swarmdo from Codex CLI, GitHub Copilot CLI, and pi** — one command wires AGENTS.md + each CLI's MCP config (idempotent, dry-run first, never touches your Claude Code setup) |
282
284
  | OpenRouter model pool | **Let swarms pick from any models you configure** — declare tier-mapped OpenRouter models in `swarmdo.config.json`; the router Thompson-samples among them per task and the execution layer dispatches the winner |
283
285
  | `swarmdo changelog` (alias `notes`) | **Release notes from conventional commits** — `--out NOTES.md` feeds `gh release create --notes-file` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -22,6 +22,12 @@ const commandLoaders = {
22
22
  // Deterministic command-output compression (rtk/headroom demand) — a
23
23
  // zero-token stream filter, distinct from caveman `compress` for files.
24
24
  compact: () => import('./compact.js'),
25
+ // Secret detection + redaction on the agent data path (gitleaks/trufflehog
26
+ // demand) — mask API keys/tokens before they reach an LLM/log/memory.
27
+ redact: () => import('./redact.js'),
28
+ // Repo→AI-context bundle (repomix demand) — one promptable blob with tree +
29
+ // token counts; swarmdo's context-assembly primitive.
30
+ pack: () => import('./pack.js'),
25
31
  // Queryable exported-symbol index (codegraph demand) — where things are
26
32
  // defined without grep+read round-trips.
27
33
  codegraph: () => import('./codegraph.js'),
@@ -255,7 +261,7 @@ export async function getCommandsByCategory() {
255
261
  // three slots from 'statusline' onward (completionsCmd received the
256
262
  // statusline module, analyzeCmd received completions, …), scrambling the
257
263
  // categorized help. Every load now has a named slot.
258
- const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd,] = await Promise.all([
264
+ const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd,] = await Promise.all([
259
265
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
260
266
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
261
267
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -263,7 +269,7 @@ export async function getCommandsByCategory() {
263
269
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
264
270
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
265
271
  loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
266
- loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'),
272
+ loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'),
267
273
  ]);
268
274
  return {
269
275
  primary: [
@@ -279,7 +285,7 @@ export async function getCommandsByCategory() {
279
285
  utility: [
280
286
  configCmd, doctorCmd, daemonCmd, completionsCmd,
281
287
  migrateCmd, workflowCmd, demoCmd,
282
- statuslineCmd, compressCmd, compactCmd, efficiencyCmd,
288
+ statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, efficiencyCmd,
283
289
  ].filter(Boolean),
284
290
  analysis: [
285
291
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `swarmdo pack` — bundle a repo (or subset) into one AI-friendly context blob.
3
+ *
4
+ * swarmdo pack # markdown bundle of the repo → stdout
5
+ * swarmdo pack src --format xml -o ctx.xml
6
+ * swarmdo pack --include '*.ts' --exclude '*.test.ts'
7
+ * swarmdo pack --tokens # just per-file + total token counts
8
+ * swarmdo pack --redact # mask secrets in each file first
9
+ *
10
+ * The fs walk lives here (default skips + .gitignore + glob include/exclude +
11
+ * binary/size guards); the pure engine (../pack/pack.ts) does formatting +
12
+ * token accounting. --redact composes the ../redact engine so secrets never
13
+ * land in the bundle.
14
+ */
15
+ import type { Command } from '../types.js';
16
+ export declare const packCommand: Command;
17
+ export default packCommand;
18
+ //# sourceMappingURL=pack.d.ts.map
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `swarmdo pack` — bundle a repo (or subset) into one AI-friendly context blob.
3
+ *
4
+ * swarmdo pack # markdown bundle of the repo → stdout
5
+ * swarmdo pack src --format xml -o ctx.xml
6
+ * swarmdo pack --include '*.ts' --exclude '*.test.ts'
7
+ * swarmdo pack --tokens # just per-file + total token counts
8
+ * swarmdo pack --redact # mask secrets in each file first
9
+ *
10
+ * The fs walk lives here (default skips + .gitignore + glob include/exclude +
11
+ * binary/size guards); the pure engine (../pack/pack.ts) does formatting +
12
+ * token accounting. --redact composes the ../redact engine so secrets never
13
+ * land in the bundle.
14
+ */
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { output } from '../output.js';
18
+ import { packFiles, makeIgnoreMatcher } from '../pack/pack.js';
19
+ import { redactText } from '../redact/redact.js';
20
+ const SKIP_DIRS = new Set([
21
+ 'node_modules', '.git', '.swarm', 'dist', 'dist-standalone', 'build',
22
+ 'coverage', '.next', '.turbo', 'out', 'vendor', '.cache',
23
+ ]);
24
+ const BINARY_EXT = new Set([
25
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'zip', 'gz', 'tar', 'tgz',
26
+ 'woff', 'woff2', 'ttf', 'eot', 'mp4', 'mov', 'mp3', 'wav', 'wasm', 'so', 'dylib',
27
+ 'dll', 'exe', 'bin', 'node', 'lock', 'jpeg',
28
+ ]);
29
+ /** Looks binary if it has a NUL in the first 8 KiB. */
30
+ function looksBinary(buf) {
31
+ const n = Math.min(buf.length, 8192);
32
+ for (let i = 0; i < n; i++)
33
+ if (buf[i] === 0)
34
+ return true;
35
+ return false;
36
+ }
37
+ function walk(o) {
38
+ const files = [];
39
+ const stack = [o.root];
40
+ while (stack.length) {
41
+ const dir = stack.pop();
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ for (const e of entries) {
50
+ const full = path.join(dir, e.name);
51
+ const rel = path.relative(o.root, full).split(path.sep).join('/');
52
+ if (e.isDirectory()) {
53
+ if (SKIP_DIRS.has(e.name))
54
+ continue;
55
+ if (o.gitignore?.(rel + '/'))
56
+ continue;
57
+ stack.push(full);
58
+ }
59
+ else if (e.isFile()) {
60
+ if (o.gitignore?.(rel))
61
+ continue;
62
+ if (o.exclude?.(rel))
63
+ continue;
64
+ if (o.include && !o.include(rel))
65
+ continue;
66
+ if (BINARY_EXT.has(e.name.slice(e.name.lastIndexOf('.') + 1).toLowerCase()))
67
+ continue;
68
+ let stat;
69
+ try {
70
+ stat = fs.statSync(full);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (stat.size > o.maxBytes)
76
+ continue;
77
+ let buf;
78
+ try {
79
+ buf = fs.readFileSync(full);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (looksBinary(buf))
85
+ continue;
86
+ files.push({ path: rel, content: buf.toString('utf8') });
87
+ }
88
+ }
89
+ }
90
+ return files;
91
+ }
92
+ function csv(v) {
93
+ if (typeof v !== 'string' || !v)
94
+ return undefined;
95
+ return v.split(',').map((s) => s.trim()).filter(Boolean);
96
+ }
97
+ async function run(ctx) {
98
+ const repoRoot = ctx.cwd || process.cwd();
99
+ const scanRoot = ctx.args[0] ? path.resolve(repoRoot, ctx.args[0]) : repoRoot;
100
+ // NB: use --style, not --format — `format` is a global flag (text/json/table,
101
+ // default 'text') that would shadow ours (#1425 removed its short alias).
102
+ const format = (typeof ctx.flags.style === 'string' ? ctx.flags.style : 'md');
103
+ if (!['md', 'xml', 'json', 'plain'].includes(format)) {
104
+ output.printError(`unknown --style '${format}' (use md|xml|json|plain)`);
105
+ return { success: false, exitCode: 1 };
106
+ }
107
+ const maxKb = typeof ctx.flags['max-file-size'] === 'string' ? parseInt(ctx.flags['max-file-size'], 10) : 512;
108
+ const maxBytes = (Number.isFinite(maxKb) ? maxKb : 512) * 1024;
109
+ const includePats = csv(ctx.flags.include);
110
+ const excludePats = csv(ctx.flags.exclude);
111
+ let gitignore;
112
+ if (ctx.flags['no-gitignore'] !== true) {
113
+ try {
114
+ const gi = fs.readFileSync(path.join(scanRoot, '.gitignore'), 'utf8').split('\n');
115
+ gitignore = makeIgnoreMatcher(gi);
116
+ }
117
+ catch { /* no .gitignore */ }
118
+ }
119
+ const files = walk({
120
+ root: scanRoot,
121
+ include: includePats ? makeIgnoreMatcher(includePats) : undefined,
122
+ exclude: excludePats ? makeIgnoreMatcher(excludePats) : undefined,
123
+ gitignore,
124
+ maxBytes,
125
+ });
126
+ if (files.length === 0) {
127
+ output.printError('no files matched — check the path, --include/--exclude, or .gitignore');
128
+ return { success: false, exitCode: 1 };
129
+ }
130
+ const opts = { format, tree: ctx.flags['no-tree'] !== true };
131
+ if (ctx.flags.redact === true) {
132
+ opts.transform = (content) => redactText(content).output;
133
+ }
134
+ const { output: bundle, stats } = packFiles(files, opts);
135
+ // --tokens: report accounting only, no bundle.
136
+ if (ctx.flags.tokens === true) {
137
+ if (ctx.flags.json === true) {
138
+ output.printJson(stats);
139
+ }
140
+ else {
141
+ for (const f of [...stats.perFile].sort((a, b) => b.tokens - a.tokens)) {
142
+ output.writeln(`${String(f.tokens).padStart(8)} ${f.path}`);
143
+ }
144
+ output.writeln(output.bold(`${String(stats.tokens).padStart(8)} TOTAL (${stats.files} files, ${(stats.bytes / 1024).toFixed(1)} KiB)`));
145
+ }
146
+ return { success: true, exitCode: 0 };
147
+ }
148
+ const outFile = (typeof ctx.flags.output === 'string' && ctx.flags.output) || (typeof ctx.flags.o === 'string' && ctx.flags.o);
149
+ if (outFile) {
150
+ fs.writeFileSync(path.resolve(repoRoot, outFile), bundle);
151
+ output.printSuccess(`packed ${stats.files} files (~${stats.tokens} tokens) → ${outFile}`);
152
+ }
153
+ else {
154
+ process.stdout.write(bundle);
155
+ if (!process.stdout.isTTY) { /* piped: keep stdout clean */ }
156
+ process.stderr.write(output.dim(`packed ${stats.files} files (~${stats.tokens} tokens)\n`));
157
+ }
158
+ return { success: true, exitCode: 0 };
159
+ }
160
+ export const packCommand = {
161
+ name: 'pack',
162
+ description: 'Bundle a repo (or subset) into one AI-friendly context blob (md/xml/json/plain) with a tree + token counts — deterministic',
163
+ options: [
164
+ { name: 'style', description: 'output format: md (default), xml, json, plain', type: 'string' },
165
+ { name: 'include', description: 'comma-separated globs; keep only matching files (e.g. "*.ts,*.md")', type: 'string' },
166
+ { name: 'exclude', description: 'comma-separated globs to skip (e.g. "*.test.ts")', type: 'string' },
167
+ { name: 'output', short: 'o', description: 'write the bundle to a file instead of stdout', type: 'string' },
168
+ { name: 'no-tree', description: 'omit the directory tree header', type: 'boolean' },
169
+ { name: 'no-gitignore', description: "don't apply the repo's .gitignore", type: 'boolean' },
170
+ { name: 'max-file-size', description: 'skip files larger than N KiB (default 512)', type: 'string' },
171
+ { name: 'redact', description: 'mask secrets in each file before bundling (uses `swarmdo redact`)', type: 'boolean' },
172
+ { name: 'tokens', description: 'print per-file + total token estimates only, no bundle', type: 'boolean' },
173
+ { name: 'json', description: 'with --tokens, emit the stats as JSON', type: 'boolean' },
174
+ ],
175
+ examples: [
176
+ { command: 'swarmdo pack src -o context.md', description: 'Bundle src/ to a markdown file' },
177
+ { command: "swarmdo pack --include '*.ts' --exclude '*.test.ts'", description: 'Only non-test TypeScript' },
178
+ { command: 'swarmdo pack --tokens', description: 'Token budget breakdown, largest first' },
179
+ { command: 'swarmdo pack --redact --style xml', description: 'Secret-safe XML bundle' },
180
+ ],
181
+ action: run,
182
+ };
183
+ export default packCommand;
184
+ //# sourceMappingURL=pack.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `swarmdo redact` — strip secrets from a stream before it reaches an LLM, a
3
+ * log, or memory. Sibling of `compact`: same stdin-filter / command-wrap shape,
4
+ * but instead of de-noising it detects API keys / tokens / private keys (rule
5
+ * catalog + entropy fallback) and masks them. Deterministic, zero tokens.
6
+ *
7
+ * cat deploy.log | swarmdo redact # stdin filter → redacted stdout
8
+ * swarmdo redact -- npm run deploy # wrap a command (stdout+stderr)
9
+ * swarmdo redact --scan -- terraform apply # scan only; exit 1 if secrets found (CI gate)
10
+ *
11
+ * Redacted text → stdout (pipeable); a one-line summary → stderr unless --quiet.
12
+ * In --scan mode nothing is rewritten and the process exits non-zero when any
13
+ * secret is present, so it gates CI. See ../redact/redact.ts.
14
+ */
15
+ import type { Command } from '../types.js';
16
+ export declare const redactCommand: Command;
17
+ export default redactCommand;
18
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `swarmdo redact` — strip secrets from a stream before it reaches an LLM, a
3
+ * log, or memory. Sibling of `compact`: same stdin-filter / command-wrap shape,
4
+ * but instead of de-noising it detects API keys / tokens / private keys (rule
5
+ * catalog + entropy fallback) and masks them. Deterministic, zero tokens.
6
+ *
7
+ * cat deploy.log | swarmdo redact # stdin filter → redacted stdout
8
+ * swarmdo redact -- npm run deploy # wrap a command (stdout+stderr)
9
+ * swarmdo redact --scan -- terraform apply # scan only; exit 1 if secrets found (CI gate)
10
+ *
11
+ * Redacted text → stdout (pipeable); a one-line summary → stderr unless --quiet.
12
+ * In --scan mode nothing is rewritten and the process exits non-zero when any
13
+ * secret is present, so it gates CI. See ../redact/redact.ts.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { output } from '../output.js';
17
+ import { redactText, scanText, formatFindingsSummary } from '../redact/redact.js';
18
+ function readStdin() {
19
+ return new Promise((resolve) => {
20
+ const chunks = [];
21
+ process.stdin.on('data', (c) => chunks.push(Buffer.from(c)));
22
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
23
+ process.stdin.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
24
+ });
25
+ }
26
+ function optsFromFlags(ctx) {
27
+ const opts = { entropy: ctx.flags['no-entropy'] !== true };
28
+ const keep = ctx.flags.keep;
29
+ if (typeof keep === 'number')
30
+ opts.keepPrefix = keep;
31
+ else if (typeof keep === 'string')
32
+ opts.keepPrefix = parseInt(keep, 10) || 0;
33
+ if (typeof ctx.flags.token === 'string')
34
+ opts.token = ctx.flags.token;
35
+ if (typeof ctx.flags.threshold === 'string')
36
+ opts.entropyThreshold = parseFloat(ctx.flags.threshold);
37
+ const allow = ctx.flags.allow;
38
+ if (typeof allow === 'string' && allow)
39
+ opts.allowlist = allow.split(',').map((s) => s.trim()).filter(Boolean);
40
+ return opts;
41
+ }
42
+ async function run(ctx) {
43
+ const opts = optsFromFlags(ctx);
44
+ const quiet = ctx.flags.quiet === true;
45
+ const scanMode = ctx.flags.scan === true;
46
+ const asJson = ctx.flags.json === true;
47
+ // Gather input: either the wrapped command's combined output, or stdin.
48
+ let input;
49
+ let wrappedCode = 0;
50
+ const wrapping = ctx.args.length > 0;
51
+ if (wrapping) {
52
+ const [cmd, ...cmdArgs] = ctx.args;
53
+ const r = spawnSync(cmd, cmdArgs, {
54
+ cwd: ctx.cwd,
55
+ encoding: 'utf8',
56
+ maxBuffer: 256 * 1024 * 1024,
57
+ stdio: ['inherit', 'pipe', 'pipe'],
58
+ });
59
+ if (r.error) {
60
+ output.printError(`failed to run ${cmd}: ${r.error.message}`);
61
+ return { success: false, exitCode: 127 };
62
+ }
63
+ input = (r.stdout || '') + (r.stderr || '');
64
+ wrappedCode = r.status ?? (r.signal ? 1 : 0);
65
+ }
66
+ else {
67
+ if (process.stdin.isTTY) {
68
+ output.writeln(output.error('Usage: swarmdo redact [--scan] -- <command> OR <command> | swarmdo redact'));
69
+ return { success: false, exitCode: 1 };
70
+ }
71
+ input = await readStdin();
72
+ }
73
+ // Scan mode: report findings, never rewrite; exit non-zero if any secret.
74
+ if (scanMode) {
75
+ const findings = scanText(input, opts);
76
+ if (asJson) {
77
+ process.stdout.write(JSON.stringify({ count: findings.length, findings }, null, 2) + '\n');
78
+ }
79
+ else if (findings.length === 0) {
80
+ if (!quiet)
81
+ process.stderr.write('redact: no secrets found\n');
82
+ }
83
+ else {
84
+ for (const f of findings) {
85
+ process.stderr.write(`${f.line}:${f.column} ${f.ruleId} ${f.description}\n`);
86
+ }
87
+ if (!quiet)
88
+ process.stderr.write(formatFindingsSummary(findings) + '\n');
89
+ }
90
+ // A wrapped command that itself failed should still surface its failure.
91
+ const code = findings.length > 0 ? 1 : wrappedCode;
92
+ return { success: code === 0, exitCode: code };
93
+ }
94
+ // Redact mode: rewrite and emit.
95
+ const { output: redacted, findings } = redactText(input, opts);
96
+ process.stdout.write(redacted);
97
+ if (asJson) {
98
+ process.stderr.write(JSON.stringify({ count: findings.length, findings }, null, 2) + '\n');
99
+ }
100
+ else if (!quiet) {
101
+ process.stderr.write(formatFindingsSummary(findings) + '\n');
102
+ }
103
+ // In wrap mode propagate the command's exit code; in filter mode success.
104
+ return { success: wrappedCode === 0, exitCode: wrapping ? wrappedCode : 0 };
105
+ }
106
+ export const redactCommand = {
107
+ name: 'redact',
108
+ description: 'Detect & mask secrets (API keys, tokens, private keys) in a stream before it reaches an LLM/log/memory — deterministic, zero tokens',
109
+ options: [
110
+ { name: 'scan', description: 'scan only: report findings and exit 1 if any secret is present (CI gate), never rewrite', type: 'boolean' },
111
+ { name: 'json', description: 'emit findings as JSON', type: 'boolean' },
112
+ { name: 'keep', description: 'keep this many leading chars of each secret (default 3; 0 = full mask)', type: 'string' },
113
+ { name: 'token', description: 'replacement token after the kept prefix (default [REDACTED])', type: 'string' },
114
+ { name: 'no-entropy', description: 'disable the high-entropy keyword=value fallback', type: 'boolean' },
115
+ { name: 'threshold', description: 'entropy threshold in bits/char for the fallback (default 3.5)', type: 'string' },
116
+ { name: 'allow', description: 'comma-separated allowlist substrings; matching secrets are left untouched', type: 'string' },
117
+ { name: 'quiet', description: 'suppress the summary on stderr', type: 'boolean' },
118
+ ],
119
+ examples: [
120
+ { command: 'cat deploy.log | swarmdo redact', description: 'Mask secrets in a log on stdin' },
121
+ { command: 'swarmdo redact -- npm run deploy', description: 'Wrap a command; redact its output, exit code propagates' },
122
+ { command: 'swarmdo redact --scan -- terraform apply', description: 'Fail CI if the output contains secrets' },
123
+ { command: 'swarmdo redact --keep 0 --json', description: 'Full-mask and emit JSON findings' },
124
+ ],
125
+ action: run,
126
+ };
127
+ export default redactCommand;
128
+ //# sourceMappingURL=redact.js.map
@@ -20,6 +20,7 @@ import { hiveMindTools } from './mcp-tools/hive-mind-tools.js';
20
20
  import { workflowTools } from './mcp-tools/workflow-tools.js';
21
21
  import { analyzeTools } from './mcp-tools/analyze-tools.js';
22
22
  import { codegraphTools } from './mcp-tools/codegraph-tools.js';
23
+ import { redactTools } from './mcp-tools/redact-tools.js';
23
24
  import { progressTools } from './mcp-tools/progress-tools.js';
24
25
  import { embeddingsTools } from './mcp-tools/embeddings-tools.js';
25
26
  import { claimsTools } from './mcp-tools/claims-tools.js';
@@ -104,6 +105,7 @@ const TOOL_GROUPS = {
104
105
  workflow: () => workflowTools,
105
106
  analyze: () => analyzeTools,
106
107
  codegraph: () => codegraphTools,
108
+ redact: () => redactTools,
107
109
  progress: () => progressTools,
108
110
  embeddings: () => embeddingsTools,
109
111
  claims: () => claimsTools,
@@ -16,6 +16,7 @@ export { workflowTools } from './workflow-tools.js';
16
16
  export { coverageRouterTools } from '../swarmvector/coverage-tools.js';
17
17
  export { analyzeTools } from './analyze-tools.js';
18
18
  export { codegraphTools } from './codegraph-tools.js';
19
+ export { redactTools } from './redact-tools.js';
19
20
  export { progressTools } from './progress-tools.js';
20
21
  export { transferTools } from './transfer-tools.js';
21
22
  export { securityTools } from './security-tools.js';
@@ -15,6 +15,7 @@ export { workflowTools } from './workflow-tools.js';
15
15
  export { coverageRouterTools } from '../swarmvector/coverage-tools.js';
16
16
  export { analyzeTools } from './analyze-tools.js';
17
17
  export { codegraphTools } from './codegraph-tools.js';
18
+ export { redactTools } from './redact-tools.js';
18
19
  export { progressTools } from './progress-tools.js';
19
20
  export { transferTools } from './transfer-tools.js';
20
21
  export { securityTools } from './security-tools.js';
@@ -23,6 +23,7 @@ const LEAN_GROUPS = [
23
23
  'agentdb', // agentdb pattern store/search
24
24
  'embeddings', // embeddings_generate / search / compare
25
25
  'codegraph', // codegraph_query / file / index — "where is X defined?"
26
+ 'redact', // redact_text / redact_scan — secret guard on the data path
26
27
  ];
27
28
  /** Balanced = lean + orchestration/session/system the average user reaches for. */
28
29
  const BALANCED_EXTRA = [
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Redact MCP Tools
3
+ *
4
+ * Give agents a secret guard on the data path: before writing tool output or
5
+ * context into memory / a message / a file, an agent can mask secrets
6
+ * (redact_text) or check for their presence (redact_scan). Deterministic —
7
+ * shares the pure engine in ../redact/redact.ts with the CLI command.
8
+ */
9
+ import type { MCPTool } from './types.js';
10
+ export declare const redactTools: MCPTool[];
11
+ //# sourceMappingURL=redact-tools.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Redact MCP Tools
3
+ *
4
+ * Give agents a secret guard on the data path: before writing tool output or
5
+ * context into memory / a message / a file, an agent can mask secrets
6
+ * (redact_text) or check for their presence (redact_scan). Deterministic —
7
+ * shares the pure engine in ../redact/redact.ts with the CLI command.
8
+ */
9
+ import { redactText, scanText } from '../redact/redact.js';
10
+ function optsOf(params) {
11
+ const opts = { entropy: params.entropy !== false };
12
+ if (typeof params.keepPrefix === 'number')
13
+ opts.keepPrefix = params.keepPrefix;
14
+ if (typeof params.token === 'string')
15
+ opts.token = params.token;
16
+ if (typeof params.entropyThreshold === 'number')
17
+ opts.entropyThreshold = params.entropyThreshold;
18
+ if (Array.isArray(params.allowlist))
19
+ opts.allowlist = params.allowlist.filter((a) => typeof a === 'string');
20
+ return opts;
21
+ }
22
+ const redactTextTool = {
23
+ name: 'redact_text',
24
+ description: 'Mask secrets (API keys, tokens, private keys) in a string before you store it in memory, put it in a message, or write it to a file. Returns the redacted text plus what was found. Deterministic; run this on any untrusted or command-derived content you are about to persist or forward.',
25
+ category: 'redact',
26
+ tags: ['security', 'secrets', 'redaction', 'safety'],
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ text: { type: 'string', description: 'The content to redact' },
31
+ keepPrefix: { type: 'number', description: 'Keep this many leading chars of each secret (default 3; 0 = full mask)' },
32
+ token: { type: 'string', description: 'Replacement token after the kept prefix (default [REDACTED])' },
33
+ entropy: { type: 'boolean', description: 'Enable the high-entropy keyword=value fallback (default true)' },
34
+ entropyThreshold: { type: 'number', description: 'Entropy threshold in bits/char for the fallback (default 3.5)' },
35
+ allowlist: { type: 'array', items: { type: 'string' }, description: 'Substrings; matching secrets are left untouched' },
36
+ },
37
+ required: ['text'],
38
+ },
39
+ handler: async (params) => {
40
+ if (typeof params.text !== 'string')
41
+ return { error: true, message: 'text is required' };
42
+ const { output, findings } = redactText(params.text, optsOf(params));
43
+ return { redacted: output, count: findings.length, findings };
44
+ },
45
+ };
46
+ const redactScanTool = {
47
+ name: 'redact_scan',
48
+ description: 'Check a string for secrets WITHOUT rewriting it — returns the list of findings (rule, line, column, description). Use to decide whether content is safe to store/forward, or to gate on it. Pair with redact_text when you actually need the masked output.',
49
+ category: 'redact',
50
+ tags: ['security', 'secrets', 'scan', 'safety'],
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ text: { type: 'string', description: 'The content to scan' },
55
+ entropy: { type: 'boolean', description: 'Enable the high-entropy keyword=value fallback (default true)' },
56
+ entropyThreshold: { type: 'number', description: 'Entropy threshold in bits/char for the fallback (default 3.5)' },
57
+ allowlist: { type: 'array', items: { type: 'string' }, description: 'Substrings to ignore' },
58
+ },
59
+ required: ['text'],
60
+ },
61
+ handler: async (params) => {
62
+ if (typeof params.text !== 'string')
63
+ return { error: true, message: 'text is required' };
64
+ const findings = scanText(params.text, optsOf(params));
65
+ return { clean: findings.length === 0, count: findings.length, findings };
66
+ },
67
+ };
68
+ export const redactTools = [redactTextTool, redactScanTool];
69
+ //# sourceMappingURL=redact-tools.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * pack.ts — bundle a repo (or a subset) into one AI-friendly context blob.
3
+ *
4
+ * swarmdo has no context-assembly primitive: `codegraph` indexes symbols for
5
+ * *querying* and `compact` de-noises a *stream*, but neither emits promptable
6
+ * source for *feeding a model*. `pack` walks the tree (respecting default skips
7
+ * + a minimal .gitignore + glob include/exclude), and writes a bundle in
8
+ * markdown / xml / json / plain with a directory tree and per-file + total
9
+ * token estimates. Deterministic — same inputs, byte-identical output.
10
+ *
11
+ * Pure engine: the fs walk is injected as a file list + reader by the caller
12
+ * (../commands/pack.ts), so this module has no fs dependency and is trivially
13
+ * testable. Optional secret redaction reuses ../redact/redact.ts.
14
+ */
15
+ export type PackFormat = 'md' | 'xml' | 'json' | 'plain';
16
+ export interface PackFile {
17
+ /** repo-relative path, POSIX separators */
18
+ path: string;
19
+ content: string;
20
+ }
21
+ export interface PackOptions {
22
+ format?: PackFormat;
23
+ /** include a directory tree at the top (default true) */
24
+ tree?: boolean;
25
+ /** optional per-file transform (e.g. secret redaction) applied before bundling */
26
+ transform?: (content: string, path: string) => string;
27
+ }
28
+ export interface PackFileStat {
29
+ path: string;
30
+ bytes: number;
31
+ tokens: number;
32
+ }
33
+ export interface PackResult {
34
+ output: string;
35
+ stats: {
36
+ files: number;
37
+ bytes: number;
38
+ tokens: number;
39
+ perFile: PackFileStat[];
40
+ };
41
+ }
42
+ /**
43
+ * Cheap, deterministic token estimate (~4 chars/token, the widely-used rule of
44
+ * thumb for English + code). Not a real BPE tokenizer — good enough for budget
45
+ * warnings, and dependency-free.
46
+ */
47
+ export declare function estimateTokens(text: string): number;
48
+ /** Build an ASCII directory tree from a sorted file list. */
49
+ export declare function buildTree(paths: string[]): string;
50
+ /** Bundle files into a single context blob per `format`. */
51
+ export declare function packFiles(input: PackFile[], opts?: PackOptions): PackResult;
52
+ /**
53
+ * Minimal .gitignore-style matcher. Handles the common cases — plain names,
54
+ * `dir/`, `*.ext` globs, leading `/` anchors, and `!` negation — not the full
55
+ * spec (no `**` depth semantics beyond substring, no nested ignore files).
56
+ * Good enough to keep obvious noise out; full semantics is a follow-up.
57
+ */
58
+ export declare function makeIgnoreMatcher(patterns: string[]): (relPath: string) => boolean;
59
+ //# sourceMappingURL=pack.d.ts.map
@@ -0,0 +1,178 @@
1
+ /**
2
+ * pack.ts — bundle a repo (or a subset) into one AI-friendly context blob.
3
+ *
4
+ * swarmdo has no context-assembly primitive: `codegraph` indexes symbols for
5
+ * *querying* and `compact` de-noises a *stream*, but neither emits promptable
6
+ * source for *feeding a model*. `pack` walks the tree (respecting default skips
7
+ * + a minimal .gitignore + glob include/exclude), and writes a bundle in
8
+ * markdown / xml / json / plain with a directory tree and per-file + total
9
+ * token estimates. Deterministic — same inputs, byte-identical output.
10
+ *
11
+ * Pure engine: the fs walk is injected as a file list + reader by the caller
12
+ * (../commands/pack.ts), so this module has no fs dependency and is trivially
13
+ * testable. Optional secret redaction reuses ../redact/redact.ts.
14
+ */
15
+ /**
16
+ * Cheap, deterministic token estimate (~4 chars/token, the widely-used rule of
17
+ * thumb for English + code). Not a real BPE tokenizer — good enough for budget
18
+ * warnings, and dependency-free.
19
+ */
20
+ export function estimateTokens(text) {
21
+ if (!text)
22
+ return 0;
23
+ return Math.ceil(text.length / 4);
24
+ }
25
+ /** Build an ASCII directory tree from a sorted file list. */
26
+ export function buildTree(paths) {
27
+ const root = {};
28
+ for (const p of [...paths].sort()) {
29
+ const parts = p.split('/');
30
+ let node = root;
31
+ for (let i = 0; i < parts.length; i++) {
32
+ const part = parts[i];
33
+ const isFile = i === parts.length - 1;
34
+ if (isFile) {
35
+ if (!node['\0files'])
36
+ node['\0files'] = [];
37
+ node['\0files'].push(part);
38
+ }
39
+ else {
40
+ node[part] = node[part] ?? {};
41
+ node = node[part];
42
+ }
43
+ }
44
+ }
45
+ const lines = [];
46
+ const walk = (node, prefix) => {
47
+ const dirs = Object.keys(node).filter((k) => k !== '\0files').sort();
48
+ const files = (node['\0files'] ?? []).sort();
49
+ const entries = [...dirs.map((d) => ['dir', d]), ...files.map((f) => ['file', f])];
50
+ entries.forEach(([kind, name], i) => {
51
+ const last = i === entries.length - 1;
52
+ lines.push(`${prefix}${last ? '└── ' : '├── '}${name}${kind === 'dir' ? '/' : ''}`);
53
+ if (kind === 'dir')
54
+ walk(node[name], prefix + (last ? ' ' : '│ '));
55
+ });
56
+ };
57
+ walk(root, '');
58
+ return lines.join('\n');
59
+ }
60
+ /** Pick a fenced-code language hint from a file extension. */
61
+ function langOf(path) {
62
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
63
+ const map = {
64
+ ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', mjs: 'javascript', cjs: 'javascript',
65
+ json: 'json', md: 'markdown', py: 'python', rs: 'rust', go: 'go', sh: 'bash', yml: 'yaml', yaml: 'yaml',
66
+ html: 'html', css: 'css', sql: 'sql', toml: 'toml',
67
+ };
68
+ return map[ext] ?? '';
69
+ }
70
+ function renderMarkdown(files, opts) {
71
+ const parts = ['# Repository context', ''];
72
+ if (opts.tree !== false) {
73
+ parts.push('## Files', '', '```', buildTree(files.map((f) => f.path)), '```', '');
74
+ }
75
+ parts.push('## Contents', '');
76
+ for (const f of files) {
77
+ const lang = langOf(f.path);
78
+ parts.push(`### ${f.path}`, '', '```' + lang, f.content.replace(/\n$/, ''), '```', '');
79
+ }
80
+ return parts.join('\n');
81
+ }
82
+ function escapeXml(s) {
83
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
84
+ }
85
+ function renderXml(files, opts) {
86
+ const parts = ['<repository>'];
87
+ if (opts.tree !== false) {
88
+ parts.push(' <tree>', escapeXml(buildTree(files.map((f) => f.path))), ' </tree>');
89
+ }
90
+ parts.push(' <files>');
91
+ for (const f of files) {
92
+ parts.push(` <file path="${escapeXml(f.path)}">`, escapeXml(f.content.replace(/\n$/, '')), ' </file>');
93
+ }
94
+ parts.push(' </files>', '</repository>');
95
+ return parts.join('\n');
96
+ }
97
+ function renderJson(files) {
98
+ return JSON.stringify({ files: files.map((f) => ({ path: f.path, content: f.content })) }, null, 2);
99
+ }
100
+ function renderPlain(files) {
101
+ const parts = [];
102
+ for (const f of files) {
103
+ parts.push('='.repeat(8) + ' ' + f.path + ' ' + '='.repeat(8), f.content.replace(/\n$/, ''), '');
104
+ }
105
+ return parts.join('\n');
106
+ }
107
+ /** Bundle files into a single context blob per `format`. */
108
+ export function packFiles(input, opts = {}) {
109
+ const files = input
110
+ .map((f) => ({ path: f.path, content: opts.transform ? opts.transform(f.content, f.path) : f.content }))
111
+ .sort((a, b) => a.path.localeCompare(b.path));
112
+ const format = opts.format ?? 'md';
113
+ let output;
114
+ switch (format) {
115
+ case 'xml':
116
+ output = renderXml(files, opts);
117
+ break;
118
+ case 'json':
119
+ output = renderJson(files);
120
+ break;
121
+ case 'plain':
122
+ output = renderPlain(files);
123
+ break;
124
+ default: output = renderMarkdown(files, opts);
125
+ }
126
+ const perFile = files.map((f) => ({
127
+ path: f.path,
128
+ bytes: Buffer.byteLength(f.content, 'utf8'),
129
+ tokens: estimateTokens(f.content),
130
+ }));
131
+ return {
132
+ output,
133
+ stats: {
134
+ files: files.length,
135
+ bytes: perFile.reduce((n, f) => n + f.bytes, 0),
136
+ tokens: estimateTokens(output),
137
+ perFile,
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Minimal .gitignore-style matcher. Handles the common cases — plain names,
143
+ * `dir/`, `*.ext` globs, leading `/` anchors, and `!` negation — not the full
144
+ * spec (no `**` depth semantics beyond substring, no nested ignore files).
145
+ * Good enough to keep obvious noise out; full semantics is a follow-up.
146
+ */
147
+ export function makeIgnoreMatcher(patterns) {
148
+ const rules = patterns
149
+ .map((p) => p.trim())
150
+ .filter((p) => p && !p.startsWith('#'))
151
+ .map((p) => {
152
+ const negate = p.startsWith('!');
153
+ let body = negate ? p.slice(1) : p;
154
+ const dirOnly = body.endsWith('/');
155
+ if (dirOnly)
156
+ body = body.slice(0, -1);
157
+ const anchored = body.startsWith('/');
158
+ if (anchored)
159
+ body = body.slice(1);
160
+ const re = new RegExp('^' +
161
+ body
162
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
163
+ .replace(/\*/g, '[^/]*')
164
+ .replace(/\?/g, '[^/]') +
165
+ (dirOnly ? '(/|$)' : '($|/)'));
166
+ return { negate, anchored, re };
167
+ });
168
+ return (relPath) => {
169
+ let ignored = false;
170
+ for (const r of rules) {
171
+ const candidates = r.anchored ? [relPath] : [relPath, ...relPath.split('/').map((_, i, a) => a.slice(i).join('/'))];
172
+ if (candidates.some((c) => r.re.test(c)))
173
+ ignored = !r.negate;
174
+ }
175
+ return ignored;
176
+ };
177
+ }
178
+ //# sourceMappingURL=pack.js.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * redact.ts — deterministic secret detection + redaction for the agent data
3
+ * path. swarmdo pipes tool output, memory, and context into LLMs constantly;
4
+ * `compact` de-noises that stream but never redacts, and `security scan` is
5
+ * CVE-oriented, not a content filter. This engine is the missing guard: it
6
+ * catches API keys / tokens / private keys via a high-confidence rule catalog
7
+ * plus a Shannon-entropy fallback for keyword-prefixed assignments, and masks
8
+ * them before content reaches a model, a log, or memory.
9
+ *
10
+ * Pure + LLM-free by design — known-secret fixtures in, deterministic masked
11
+ * bytes out. The CLI wrapper (../commands/redact.ts) and an MCP tool share it.
12
+ */
13
+ export interface RedactRule {
14
+ /** stable slug, e.g. 'aws-access-key' */
15
+ id: string;
16
+ /** human description for --scan output */
17
+ description: string;
18
+ /** global regex; the whole match (or capture group `group`) is the secret */
19
+ regex: RegExp;
20
+ /** if set, redact this capture group rather than the whole match */
21
+ group?: number;
22
+ }
23
+ export interface Finding {
24
+ ruleId: string;
25
+ description: string;
26
+ /** 1-based line number */
27
+ line: number;
28
+ /** 1-based column of the secret within the line */
29
+ column: number;
30
+ /** the raw secret text that matched */
31
+ match: string;
32
+ /** what it was replaced with */
33
+ redacted: string;
34
+ }
35
+ export interface RedactOptions {
36
+ /** keep this many leading chars of a secret, mask the rest (default 3) */
37
+ keepPrefix?: number;
38
+ /** the replacement token appended after the kept prefix (default '[REDACTED]') */
39
+ token?: string;
40
+ /** enable the entropy fallback for keyword=value assignments (default true) */
41
+ entropy?: boolean;
42
+ /** Shannon-entropy threshold (bits/char) above which a keyworded value is a secret (default 3.5) */
43
+ entropyThreshold?: number;
44
+ /** substrings/regexes; any secret whose match includes one is left untouched */
45
+ allowlist?: Array<string | RegExp>;
46
+ }
47
+ export interface RedactResult {
48
+ output: string;
49
+ findings: Finding[];
50
+ }
51
+ /**
52
+ * High-confidence catalog. Ordered — more specific rules (e.g. Anthropic
53
+ * `sk-ant-`) come before broader ones (`sk-` OpenAI) so the specific rule wins
54
+ * and we don't double-count. Patterns are conservative to keep false positives
55
+ * low; the entropy fallback covers the long tail of custom secrets.
56
+ */
57
+ export declare const RULES: RedactRule[];
58
+ /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
59
+ export declare function shannonEntropy(s: string): number;
60
+ /** Mask a secret: keep `keepPrefix` leading chars, then the token. */
61
+ export declare function maskSecret(secret: string, opts?: RedactOptions): string;
62
+ /** Scan without rewriting — returns findings only (for --scan / CI gating). */
63
+ export declare function scanText(text: string, opts?: RedactOptions): Finding[];
64
+ /** Redact secrets in `text`, returning the rewritten output + findings. */
65
+ export declare function redactText(text: string, opts?: RedactOptions): RedactResult;
66
+ /** One-line human summary for stderr. */
67
+ export declare function formatFindingsSummary(findings: Finding[]): string;
68
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * redact.ts — deterministic secret detection + redaction for the agent data
3
+ * path. swarmdo pipes tool output, memory, and context into LLMs constantly;
4
+ * `compact` de-noises that stream but never redacts, and `security scan` is
5
+ * CVE-oriented, not a content filter. This engine is the missing guard: it
6
+ * catches API keys / tokens / private keys via a high-confidence rule catalog
7
+ * plus a Shannon-entropy fallback for keyword-prefixed assignments, and masks
8
+ * them before content reaches a model, a log, or memory.
9
+ *
10
+ * Pure + LLM-free by design — known-secret fixtures in, deterministic masked
11
+ * bytes out. The CLI wrapper (../commands/redact.ts) and an MCP tool share it.
12
+ */
13
+ /**
14
+ * High-confidence catalog. Ordered — more specific rules (e.g. Anthropic
15
+ * `sk-ant-`) come before broader ones (`sk-` OpenAI) so the specific rule wins
16
+ * and we don't double-count. Patterns are conservative to keep false positives
17
+ * low; the entropy fallback covers the long tail of custom secrets.
18
+ */
19
+ export const RULES = [
20
+ { id: 'private-key', description: 'Private key block header', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
21
+ { id: 'aws-access-key', description: 'AWS access key ID', regex: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASCA)[0-9A-Z]{16}\b/g },
22
+ { id: 'github-pat', description: 'GitHub personal access token', regex: /\bghp_[0-9A-Za-z]{36}\b/g },
23
+ { id: 'github-oauth', description: 'GitHub OAuth/app token', regex: /\b(?:gho|ghu|ghs|ghr)_[0-9A-Za-z]{36}\b/g },
24
+ { id: 'github-fine-pat', description: 'GitHub fine-grained PAT', regex: /\bgithub_pat_[0-9A-Za-z_]{82}\b/g },
25
+ { id: 'gitlab-pat', description: 'GitLab personal access token', regex: /\bglpat-[0-9A-Za-z_-]{20}\b/g },
26
+ { id: 'anthropic-key', description: 'Anthropic API key', regex: /\bsk-ant-[0-9A-Za-z-]{16,}\b/g },
27
+ { id: 'openai-key', description: 'OpenAI API key', regex: /\bsk-(?:proj-)?[0-9A-Za-z]{20,}\b/g },
28
+ { id: 'google-api-key', description: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
29
+ { id: 'slack-token', description: 'Slack token', regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
30
+ { id: 'slack-webhook', description: 'Slack incoming webhook', regex: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z_]+/g },
31
+ { id: 'stripe-key', description: 'Stripe secret/restricted key', regex: /\b(?:sk|rk)_live_[0-9A-Za-z]{24,}\b/g },
32
+ { id: 'sendgrid-key', description: 'SendGrid API key', regex: /\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}\b/g },
33
+ { id: 'twilio-key', description: 'Twilio API key SID', regex: /\bSK[0-9a-fA-F]{32}\b/g },
34
+ { id: 'npm-token', description: 'npm access token', regex: /\bnpm_[0-9A-Za-z]{36}\b/g },
35
+ { id: 'jwt', description: 'JSON Web Token', regex: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\b/g },
36
+ ];
37
+ /** Keyword-prefixed assignment, e.g. `api_key = "..."` — the value is group 1. */
38
+ const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
39
+ /** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
40
+ export function shannonEntropy(s) {
41
+ if (!s)
42
+ return 0;
43
+ const freq = new Map();
44
+ for (const ch of s)
45
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
46
+ let bits = 0;
47
+ for (const n of freq.values()) {
48
+ const p = n / s.length;
49
+ bits -= p * Math.log2(p);
50
+ }
51
+ return bits;
52
+ }
53
+ /** Mask a secret: keep `keepPrefix` leading chars, then the token. */
54
+ export function maskSecret(secret, opts = {}) {
55
+ const keep = Math.max(0, Math.min(opts.keepPrefix ?? 3, secret.length));
56
+ const token = opts.token ?? '[REDACTED]';
57
+ return secret.slice(0, keep) + token;
58
+ }
59
+ function isAllowlisted(match, allowlist) {
60
+ if (!allowlist || allowlist.length === 0)
61
+ return false;
62
+ return allowlist.some((a) => (typeof a === 'string' ? match.includes(a) : a.test(match)));
63
+ }
64
+ /** Collect all rule + entropy hits across the text, deduped by offset. */
65
+ function collectHits(text, opts) {
66
+ const hits = [];
67
+ const claimed = []; // [start,end) ranges already taken
68
+ const overlaps = (start, end) => claimed.some(([s, e]) => start < e && end > s);
69
+ for (const rule of RULES) {
70
+ rule.regex.lastIndex = 0;
71
+ let m;
72
+ while ((m = rule.regex.exec(text)) !== null) {
73
+ const secret = rule.group != null ? m[rule.group] : m[0];
74
+ if (secret == null)
75
+ continue;
76
+ const start = rule.group != null ? m.index + m[0].indexOf(secret) : m.index;
77
+ const end = start + secret.length;
78
+ if (overlaps(start, end))
79
+ continue;
80
+ if (isAllowlisted(secret, opts.allowlist))
81
+ continue;
82
+ claimed.push([start, end]);
83
+ hits.push({ ruleId: rule.id, description: rule.description, index: start, match: secret });
84
+ if (m.index === rule.regex.lastIndex)
85
+ rule.regex.lastIndex++; // guard zero-width
86
+ }
87
+ }
88
+ if (opts.entropy !== false) {
89
+ const threshold = opts.entropyThreshold ?? 3.5;
90
+ ASSIGNMENT_RE.lastIndex = 0;
91
+ let m;
92
+ while ((m = ASSIGNMENT_RE.exec(text)) !== null) {
93
+ const value = m[1];
94
+ const start = m.index + m[0].indexOf(value);
95
+ const end = start + value.length;
96
+ if (overlaps(start, end))
97
+ continue;
98
+ if (isAllowlisted(value, opts.allowlist))
99
+ continue;
100
+ if (shannonEntropy(value) < threshold)
101
+ continue;
102
+ claimed.push([start, end]);
103
+ hits.push({ ruleId: 'high-entropy-assignment', description: 'High-entropy secret assignment', index: start, match: value });
104
+ }
105
+ }
106
+ return hits.sort((a, b) => a.index - b.index);
107
+ }
108
+ /** Map an absolute offset to 1-based {line, column}. */
109
+ function lineColOf(text, index) {
110
+ let line = 1;
111
+ let last = -1;
112
+ for (let i = 0; i < index; i++) {
113
+ if (text[i] === '\n') {
114
+ line++;
115
+ last = i;
116
+ }
117
+ }
118
+ return { line, column: index - last };
119
+ }
120
+ /** Scan without rewriting — returns findings only (for --scan / CI gating). */
121
+ export function scanText(text, opts = {}) {
122
+ return collectHits(text, opts).map((h) => {
123
+ const { line, column } = lineColOf(text, h.index);
124
+ return { ruleId: h.ruleId, description: h.description, line, column, match: h.match, redacted: maskSecret(h.match, opts) };
125
+ });
126
+ }
127
+ /** Redact secrets in `text`, returning the rewritten output + findings. */
128
+ export function redactText(text, opts = {}) {
129
+ const hits = collectHits(text, opts);
130
+ const findings = [];
131
+ let out = '';
132
+ let cursor = 0;
133
+ for (const h of hits) {
134
+ const { line, column } = lineColOf(text, h.index);
135
+ const redacted = maskSecret(h.match, opts);
136
+ out += text.slice(cursor, h.index) + redacted;
137
+ cursor = h.index + h.match.length;
138
+ findings.push({ ruleId: h.ruleId, description: h.description, line, column, match: h.match, redacted });
139
+ }
140
+ out += text.slice(cursor);
141
+ return { output: out, findings };
142
+ }
143
+ /** One-line human summary for stderr. */
144
+ export function formatFindingsSummary(findings) {
145
+ if (findings.length === 0)
146
+ return 'redact: no secrets found';
147
+ const byRule = new Map();
148
+ for (const f of findings)
149
+ byRule.set(f.ruleId, (byRule.get(f.ruleId) ?? 0) + 1);
150
+ const parts = [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => `${r}:${n}`);
151
+ return `redact: ${findings.length} secret${findings.length === 1 ? '' : 's'} redacted (${parts.join(', ')})`;
152
+ }
153
+ //# sourceMappingURL=redact.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmdo/cli",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",