@nfinitmonkeys/clan-engine 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brain-build.d.ts +33 -0
- package/dist/brain-build.js +237 -0
- package/dist/cli.js +49 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/sanitize.d.ts +23 -0
- package/dist/sanitize.js +71 -0
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brain-build — mine a clan's EXISTING files into brain wiki pages, so a new
|
|
3
|
+
* clan doesn't start with an empty brain. Sources: README, docs/, CLAUDE.md,
|
|
4
|
+
* package manifests, top-level code structure, the engine's inventory of the
|
|
5
|
+
* user's existing Claude Code assets, and (opt-in) Claude Code session memory.
|
|
6
|
+
*
|
|
7
|
+
* SAFETY (non-negotiable): every candidate body is scanned with scanSensitive
|
|
8
|
+
* BEFORE it becomes a page; anything that trips (secrets / infra ids / PHI) is
|
|
9
|
+
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
|
+
* create-only — accumulated brain content is never clobbered.
|
|
11
|
+
*
|
|
12
|
+
* Mechanical (no LLM required) — the right no-key base layer. LLM curation can
|
|
13
|
+
* layer on top later.
|
|
14
|
+
*/
|
|
15
|
+
import type { ClanIdentity, GeneratedFile } from './types.js';
|
|
16
|
+
export interface HeldBack {
|
|
17
|
+
from: string;
|
|
18
|
+
reasons: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface BrainBuildResult {
|
|
21
|
+
pages: GeneratedFile[];
|
|
22
|
+
heldBack: HeldBack[];
|
|
23
|
+
scanned: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mine existing files into brain pages. Every candidate is scanned; flagged
|
|
27
|
+
* ones are held back. Returns create-candidate pages + the held-back report.
|
|
28
|
+
*/
|
|
29
|
+
export declare function brainBuild(repo: string, identity: ClanIdentity, opts?: {
|
|
30
|
+
home?: string;
|
|
31
|
+
hipaa?: boolean;
|
|
32
|
+
includeMemory?: boolean;
|
|
33
|
+
}): BrainBuildResult;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brain-build — mine a clan's EXISTING files into brain wiki pages, so a new
|
|
3
|
+
* clan doesn't start with an empty brain. Sources: README, docs/, CLAUDE.md,
|
|
4
|
+
* package manifests, top-level code structure, the engine's inventory of the
|
|
5
|
+
* user's existing Claude Code assets, and (opt-in) Claude Code session memory.
|
|
6
|
+
*
|
|
7
|
+
* SAFETY (non-negotiable): every candidate body is scanned with scanSensitive
|
|
8
|
+
* BEFORE it becomes a page; anything that trips (secrets / infra ids / PHI) is
|
|
9
|
+
* HELD BACK, never written, and reported for the operator to review. Pages are
|
|
10
|
+
* create-only — accumulated brain content is never clobbered.
|
|
11
|
+
*
|
|
12
|
+
* Mechanical (no LLM required) — the right no-key base layer. LLM curation can
|
|
13
|
+
* layer on top later.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, existsSync, readdirSync, lstatSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { brainRoot, markerLines } from './templates.js';
|
|
19
|
+
import { scanSensitive } from './sanitize.js';
|
|
20
|
+
function slugify(s) {
|
|
21
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'untitled';
|
|
22
|
+
}
|
|
23
|
+
/** Collapse to a single clean line for a YAML scalar / title. */
|
|
24
|
+
function oneLine(s) {
|
|
25
|
+
return String(s).replace(/\s+/g, ' ').trim();
|
|
26
|
+
}
|
|
27
|
+
function read(p) { try {
|
|
28
|
+
return readFileSync(p, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
} }
|
|
33
|
+
function listMd(dir, depth = 3) {
|
|
34
|
+
if (depth < 0 || !existsSync(dir))
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(dir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (e.startsWith('.') || e === 'node_modules')
|
|
46
|
+
continue;
|
|
47
|
+
const full = join(dir, e);
|
|
48
|
+
// lstat, not stat: never follow a symlink (a docs/x -> / link must not
|
|
49
|
+
// pull the whole filesystem into the brain).
|
|
50
|
+
let st;
|
|
51
|
+
try {
|
|
52
|
+
st = lstatSync(full);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (st.isSymbolicLink())
|
|
58
|
+
continue;
|
|
59
|
+
if (st.isDirectory())
|
|
60
|
+
out.push(...listMd(full, depth - 1));
|
|
61
|
+
else if (e.endsWith('.md'))
|
|
62
|
+
out.push(full);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** README H2 sections → concept candidates. */
|
|
67
|
+
function mineReadme(repo) {
|
|
68
|
+
const body = read(join(repo, 'README.md')) ?? read(join(repo, 'readme.md'));
|
|
69
|
+
if (!body)
|
|
70
|
+
return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
const parts = body.split(/^##\s+/m);
|
|
73
|
+
for (let i = 1; i < parts.length; i++) {
|
|
74
|
+
const seg = parts[i];
|
|
75
|
+
const title = seg.split('\n')[0].trim();
|
|
76
|
+
const content = seg.slice(seg.indexOf('\n') + 1).trim();
|
|
77
|
+
if (title && content.length > 20)
|
|
78
|
+
out.push({ category: 'concepts', slug: slugify(title), title, type: 'concept', from: 'README.md', body: content });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** docs/**.md → source candidates (recursive, bounded). */
|
|
83
|
+
function mineDocs(repo) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const f of listMd(join(repo, 'docs'))) {
|
|
86
|
+
const body = read(f);
|
|
87
|
+
if (!body || body.length < 40)
|
|
88
|
+
continue;
|
|
89
|
+
const rel = f.slice(repo.length + 1);
|
|
90
|
+
const title = (/^#\s+(.+)$/m.exec(body)?.[1] ?? rel).trim();
|
|
91
|
+
out.push({ category: 'sources', slug: slugify(rel.replace(/\.md$/, '')), title, type: 'source', from: rel, body });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** CLAUDE.md → a source page (the thing bootstrap-clan never converted). Strips
|
|
96
|
+
* the engine's own geo-tier/identity block so we don't re-ingest boilerplate. */
|
|
97
|
+
function mineClaudeMd(repo) {
|
|
98
|
+
const body = read(join(repo, 'CLAUDE.md'));
|
|
99
|
+
if (!body || body.length < 40)
|
|
100
|
+
return [];
|
|
101
|
+
const cleaned = body.replace(/<!--\s*geo-tier:[^>]*-->/g, '').trim();
|
|
102
|
+
if (cleaned.length < 40)
|
|
103
|
+
return [];
|
|
104
|
+
return [{ category: 'sources', slug: 'project-claude-md', title: 'Project CLAUDE.md', type: 'source', from: 'CLAUDE.md', body: cleaned }];
|
|
105
|
+
}
|
|
106
|
+
/** package.json / pyproject / Cargo → an entity overview. */
|
|
107
|
+
function mineManifest(repo) {
|
|
108
|
+
const pj = read(join(repo, 'package.json'));
|
|
109
|
+
if (pj) {
|
|
110
|
+
try {
|
|
111
|
+
const p = JSON.parse(pj);
|
|
112
|
+
const depsObj = (p.dependencies && typeof p.dependencies === 'object' && !Array.isArray(p.dependencies)) ? p.dependencies : {};
|
|
113
|
+
const devObj = (p.devDependencies && typeof p.devDependencies === 'object' && !Array.isArray(p.devDependencies)) ? p.devDependencies : {};
|
|
114
|
+
const deps = Object.keys({ ...depsObj, ...devObj });
|
|
115
|
+
const name = String(p.name ?? 'project');
|
|
116
|
+
const body = `**Name:** ${name}\n**Version:** ${String(p.version ?? '?')}\n**Description:** ${String(p.description ?? '(none)')}\n\n**Dependencies (${deps.length}):** ${deps.slice(0, 40).join(', ')}`;
|
|
117
|
+
return [{ category: 'entities', slug: 'project', title: name, type: 'entity', from: 'package.json', body }];
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
for (const [file, label] of [['pyproject.toml', 'Python project'], ['Cargo.toml', 'Rust crate']]) {
|
|
122
|
+
const c = read(join(repo, file));
|
|
123
|
+
if (c)
|
|
124
|
+
return [{ category: 'entities', slug: 'project', title: label, type: 'entity', from: file, body: c.slice(0, 800) }];
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
/** Top-level code directories → entity stubs. */
|
|
129
|
+
function mineCodeDirs(repo) {
|
|
130
|
+
const skip = new Set(['.git', '.claude', '.clan', '.brain', '.jungle', '.district', '.brains', 'node_modules', 'dist', 'build', 'docs', '.github', '.vscode']);
|
|
131
|
+
const out = [];
|
|
132
|
+
let entries = [];
|
|
133
|
+
try {
|
|
134
|
+
entries = readdirSync(repo);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
if (skip.has(e) || e.startsWith('.'))
|
|
141
|
+
continue;
|
|
142
|
+
let st;
|
|
143
|
+
try {
|
|
144
|
+
st = lstatSync(join(repo, e));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (st.isDirectory())
|
|
150
|
+
out.push({ category: 'entities', slug: slugify(e), title: e, type: 'entity', from: e + '/', body: `Top-level code directory \`${e}/\`. (Stub — the Alpha fills this in as it learns the module.)` });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/** The engine's inventory of existing assets → a "tools this project already has" concept. */
|
|
155
|
+
function mineInventory(repo) {
|
|
156
|
+
const raw = read(join(repo, '.clan', 'inventory.json'));
|
|
157
|
+
if (!raw)
|
|
158
|
+
return [];
|
|
159
|
+
try {
|
|
160
|
+
const items = (JSON.parse(raw).items ?? []);
|
|
161
|
+
if (!items.length)
|
|
162
|
+
return [];
|
|
163
|
+
const lines = items.map(i => `- **${i.name}** (${i.kind}, ${i.scope})${i.description ? ` — ${i.description}` : ''}`);
|
|
164
|
+
return [{ category: 'concepts', slug: 'existing-tools', title: 'Existing tools in this project', type: 'concept', from: '.clan/inventory.json', body: `The Alpha should PREFER these existing assets where they overlap, rather than duplicating:\n\n${lines.join('\n')}` }];
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Claude Code project-path encoding: every non-alphanumeric char → '-'
|
|
171
|
+
* (covers /, ., _, and on Windows \ and the drive colon). */
|
|
172
|
+
function encodeRepoPath(repo) {
|
|
173
|
+
return repo.replace(/[^a-zA-Z0-9]/g, '-');
|
|
174
|
+
}
|
|
175
|
+
/** Opt-in: Claude Code session memory for THIS repo → source candidates. */
|
|
176
|
+
function mineMemory(repo, home) {
|
|
177
|
+
const dir = join(home, '.claude', 'projects', encodeRepoPath(repo), 'memory');
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const f of listMd(dir, 1)) {
|
|
180
|
+
const base = f.slice(dir.length + 1);
|
|
181
|
+
if (base === 'MEMORY.md')
|
|
182
|
+
continue; // the index, not content
|
|
183
|
+
const body = read(f);
|
|
184
|
+
if (!body || body.length < 40)
|
|
185
|
+
continue;
|
|
186
|
+
const title = (/^name:\s*(.+)$/m.exec(body)?.[1] ?? base.replace(/\.md$/, '')).trim();
|
|
187
|
+
out.push({ category: 'sources', slug: 'memory--' + slugify(base.replace(/\.md$/, '')), title, type: 'source', from: `session-memory/${base}`, body });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Mine existing files into brain pages. Every candidate is scanned; flagged
|
|
193
|
+
* ones are held back. Returns create-candidate pages + the held-back report.
|
|
194
|
+
*/
|
|
195
|
+
export function brainBuild(repo, identity, opts = {}) {
|
|
196
|
+
const home = opts.home ?? homedir();
|
|
197
|
+
const candidates = [
|
|
198
|
+
...mineManifest(repo),
|
|
199
|
+
...mineReadme(repo),
|
|
200
|
+
...mineClaudeMd(repo),
|
|
201
|
+
...mineDocs(repo),
|
|
202
|
+
...mineCodeDirs(repo),
|
|
203
|
+
...mineInventory(repo),
|
|
204
|
+
...(opts.includeMemory ? mineMemory(repo, home) : []),
|
|
205
|
+
];
|
|
206
|
+
const root = brainRoot(identity);
|
|
207
|
+
const pages = [];
|
|
208
|
+
const heldBack = [];
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
for (const c of candidates) {
|
|
211
|
+
// Scan the body AND the derived title AND the source path — a secret/PHI in
|
|
212
|
+
// a filename or heading is just as leaky as one in the body.
|
|
213
|
+
const scan = scanSensitive(`${c.body}\n${c.title}\n${c.from}`, { hipaa: opts.hipaa });
|
|
214
|
+
if (scan.flagged) {
|
|
215
|
+
heldBack.push({ from: c.from, reasons: scan.reasons });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// Unique slug within its category (suffix on collision — never silently drop).
|
|
219
|
+
let path = `${root}/wiki/${c.category}/${c.slug}.md`;
|
|
220
|
+
for (let n = 2; seen.has(path); n++)
|
|
221
|
+
path = `${root}/wiki/${c.category}/${c.slug}-${n}.md`;
|
|
222
|
+
seen.add(path);
|
|
223
|
+
// Create-only guaranteed here (not just in the CLI): never clobber an
|
|
224
|
+
// existing brain page, so a programmatic caller can't overwrite memory.
|
|
225
|
+
if (existsSync(join(repo, path)))
|
|
226
|
+
continue;
|
|
227
|
+
// Safe frontmatter: JSON-quote scalars so a title/path containing ':' '#'
|
|
228
|
+
// a newline, or a leading '-' can't break the YAML.
|
|
229
|
+
const title = oneLine(c.title);
|
|
230
|
+
pages.push({
|
|
231
|
+
path,
|
|
232
|
+
managed: true,
|
|
233
|
+
content: `---\nname: ${JSON.stringify(title)}\ndescription: ${JSON.stringify('mined from ' + c.from)}\ntype: ${JSON.stringify(c.type)}\nsource: ${JSON.stringify(c.from)}\n${markerLines()}\n---\n\n# ${title}\n\n${c.body}\n`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return { pages, heldBack, scanned: candidates.length };
|
|
237
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
* clan-engine undo [--repo .] [--journal <path>]
|
|
11
11
|
* clan-engine doctor [--repo .] [--json]
|
|
12
12
|
*/
|
|
13
|
-
import { resolve } from 'path';
|
|
13
|
+
import { resolve, join } from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
14
15
|
import { inspect } from './inspect.js';
|
|
15
16
|
import { reconcile } from './reconcile.js';
|
|
16
17
|
import { apply } from './apply.js';
|
|
17
18
|
import { undo, listJournals } from './undo.js';
|
|
18
19
|
import { doctor } from './doctor.js';
|
|
20
|
+
import { brainBuild } from './brain-build.js';
|
|
19
21
|
import { ENGINE_VERSION, LAYOUT_VERSION } from './types.js';
|
|
20
22
|
function parseFlags(argv) {
|
|
21
23
|
const out = {};
|
|
@@ -127,6 +129,46 @@ async function main() {
|
|
|
127
129
|
}
|
|
128
130
|
break;
|
|
129
131
|
}
|
|
132
|
+
case 'brain-build': {
|
|
133
|
+
const ins = inspect(repo, { tierFlag: flags['tier'] });
|
|
134
|
+
const identity = resolveIdentity(ins, flags);
|
|
135
|
+
const { pages, heldBack, scanned } = brainBuild(repo, identity, {
|
|
136
|
+
includeMemory: flags['memory'] === 'true',
|
|
137
|
+
// PHI scanning is ON by default; --no-phi opts out.
|
|
138
|
+
hipaa: flags['no-phi'] === 'true' ? false : undefined,
|
|
139
|
+
});
|
|
140
|
+
// Create-only: never clobber an existing brain page.
|
|
141
|
+
const toCreate = pages
|
|
142
|
+
.filter(p => !existsSync(join(repo, p.path)))
|
|
143
|
+
.map(p => ({ kind: 'create', path: p.path, content: p.content, note: `brain page` }));
|
|
144
|
+
const plan = { identity, repo, actions: toCreate };
|
|
145
|
+
// Default dry-run — brain-build reads broadly (docs, code, optionally
|
|
146
|
+
// session memory), so writing is opt-in via --write.
|
|
147
|
+
const doWrite = flags['write'] === 'true' || flags['apply'] === 'true';
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(JSON.stringify({ scanned, built: toCreate.length, heldBack, wrote: doWrite }, null, 2));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(`\nclan-engine brain-build — scanned ${scanned} source(s)`);
|
|
153
|
+
console.log(` ${toCreate.length} new brain page(s)${doWrite ? ' written' : ' (dry-run — pass --write to save)'}`);
|
|
154
|
+
for (const a of toCreate.slice(0, 12))
|
|
155
|
+
console.log(` + ${a.path}`);
|
|
156
|
+
if (toCreate.length > 12)
|
|
157
|
+
console.log(` … +${toCreate.length - 12} more`);
|
|
158
|
+
if (heldBack.length) {
|
|
159
|
+
console.log(`\n ⚠ ${heldBack.length} source(s) HELD BACK (sensitive — review before publishing):`);
|
|
160
|
+
for (const h of heldBack)
|
|
161
|
+
console.log(` · ${h.from} — ${h.reasons.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (doWrite && toCreate.length) {
|
|
165
|
+
const res = apply(plan, { at: stamp() });
|
|
166
|
+
if (!json && res.journalPath)
|
|
167
|
+
console.log(`\n undo: clan-engine undo --journal ${res.journalPath}`);
|
|
168
|
+
}
|
|
169
|
+
console.log();
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
130
172
|
case 'undo': {
|
|
131
173
|
let jp = flags['journal'];
|
|
132
174
|
if (!jp) {
|
|
@@ -166,9 +208,12 @@ async function main() {
|
|
|
166
208
|
|
|
167
209
|
inspect map a repo's state (add --json for machine output)
|
|
168
210
|
plan show what setup would write (dry-run; --alpha --name --tier)
|
|
169
|
-
apply
|
|
170
|
-
|
|
171
|
-
|
|
211
|
+
apply write it (uniform backups + a journal for undo)
|
|
212
|
+
brain-build mine existing files (README/docs/CLAUDE.md/code/inventory,
|
|
213
|
+
+--memory) into brain pages; sensitive content held back.
|
|
214
|
+
dry-run by default; --write to save
|
|
215
|
+
undo reverse the last apply (--journal <path> to pick one)
|
|
216
|
+
doctor validate the whole loop (config, mcp name, brain, hook, creds)
|
|
172
217
|
`);
|
|
173
218
|
}
|
|
174
219
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export { apply, type ApplyOptions } from './apply.js';
|
|
|
13
13
|
export { undo, listJournals, type UndoResult } from './undo.js';
|
|
14
14
|
export { doctor } from './doctor.js';
|
|
15
15
|
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
+
export { brainBuild, type BrainBuildResult, type HeldBack } from './brain-build.js';
|
|
17
|
+
export { scanSensitive, type ScanResult } from './sanitize.js';
|
|
16
18
|
export { planMcpJson, jungleLocalServer, type JungleCreds } from './mcp.js';
|
|
17
19
|
export { planBrainHook } from './hooks.js';
|
|
18
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ export { apply } from './apply.js';
|
|
|
13
13
|
export { undo, listJournals } from './undo.js';
|
|
14
14
|
export { doctor } from './doctor.js';
|
|
15
15
|
export { scanInventory, writeInventoryManifest } from './inventory.js';
|
|
16
|
+
export { brainBuild } from './brain-build.js';
|
|
17
|
+
export { scanSensitive } from './sanitize.js';
|
|
16
18
|
export { planMcpJson, jungleLocalServer } from './mcp.js';
|
|
17
19
|
export { planBrainHook } from './hooks.js';
|
|
18
20
|
export { claudeSurface, brainScaffold, claudeMdIdentity, loadBrainScript, geoTierMarker, hasManagedMarker, findGeoTierMarker, markerLines, hookCommand, brainRoot, } from './templates.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret / infra / PHI filter — the non-negotiable gate on anything that goes
|
|
3
|
+
* into a brain. A clan's brain_index is cross-clan-readable by council/master
|
|
4
|
+
* keys, and repo docs + session memory routinely hold ssh keys, prod creds,
|
|
5
|
+
* connection strings, and (for HIPAA clans) PHI. Every candidate body is scanned
|
|
6
|
+
* BEFORE it becomes a brain page; anything that trips is HELD BACK, never
|
|
7
|
+
* written. Reasons are LABELS ONLY — the matched secret text is never returned,
|
|
8
|
+
* so a scan result is safe to log.
|
|
9
|
+
*
|
|
10
|
+
* PHI scanning is ON BY DEFAULT (opt out with { hipaa: false }): a false-positive
|
|
11
|
+
* hold-back is cheap; a PHI leak is catastrophic.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScanResult {
|
|
14
|
+
flagged: boolean;
|
|
15
|
+
reasons: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Scan text for secrets + infra identifiers (always) and PHI (unless
|
|
19
|
+
* hipaa:false is explicitly passed — PHI is scanned by default).
|
|
20
|
+
*/
|
|
21
|
+
export declare function scanSensitive(text: string, opts?: {
|
|
22
|
+
hipaa?: boolean;
|
|
23
|
+
}): ScanResult;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret / infra / PHI filter — the non-negotiable gate on anything that goes
|
|
3
|
+
* into a brain. A clan's brain_index is cross-clan-readable by council/master
|
|
4
|
+
* keys, and repo docs + session memory routinely hold ssh keys, prod creds,
|
|
5
|
+
* connection strings, and (for HIPAA clans) PHI. Every candidate body is scanned
|
|
6
|
+
* BEFORE it becomes a brain page; anything that trips is HELD BACK, never
|
|
7
|
+
* written. Reasons are LABELS ONLY — the matched secret text is never returned,
|
|
8
|
+
* so a scan result is safe to log.
|
|
9
|
+
*
|
|
10
|
+
* PHI scanning is ON BY DEFAULT (opt out with { hipaa: false }): a false-positive
|
|
11
|
+
* hold-back is cheap; a PHI leak is catastrophic.
|
|
12
|
+
*/
|
|
13
|
+
const SECRET_PATTERNS = [
|
|
14
|
+
['private-key-block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP |ENCRYPTED )?PRIVATE KEY-----/],
|
|
15
|
+
['ssh-public-key', /\bssh-(?:rsa|ed25519|dss)\s+AAAA[0-9A-Za-z+/]{20,}/],
|
|
16
|
+
['aws-access-key-id', /\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{16}\b/],
|
|
17
|
+
['aws-secret-assignment', /\b(?:aws.?)?secret.?access.?key\b\s*[:=]\s*\S{16,}/i],
|
|
18
|
+
['gcp-api-key', /\bAIza[0-9A-Za-z_\-]{35,}/],
|
|
19
|
+
['gcp-service-account', /\b[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com\b|"private_key_id"\s*:/],
|
|
20
|
+
['azure-storage-key', /\bAccountKey\s*=\s*[A-Za-z0-9+/=]{20,}/i],
|
|
21
|
+
['azure-sas', /\bSharedAccessSignature\b|[?&]sig=[A-Za-z0-9%]{20,}/],
|
|
22
|
+
['stripe-key', /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bwhsec_[A-Za-z0-9]{16,}\b/],
|
|
23
|
+
['twilio-key', /\b(?:SK|AC)[0-9a-f]{32}\b/],
|
|
24
|
+
['sendgrid-key', /\bSG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}\b/],
|
|
25
|
+
['github-token', /\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b/],
|
|
26
|
+
['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
|
|
27
|
+
['openai-anthropic-cortex-key', /\bsk-(?:ant-|cortex-|proj-|live-)?[A-Za-z0-9_-]{16,}\b/],
|
|
28
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/],
|
|
29
|
+
['bearer-or-authorization', /\b(?:authorization|bearer)\b\s*:?\s*(?:bearer\s+)?[A-Za-z0-9._\-]{16,}/i],
|
|
30
|
+
['uri-with-embedded-creds', /\b[a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+:[^\s@/]+@/i],
|
|
31
|
+
['dotnet-connection-string', /\b(?:Password|Pwd)\s*=\s*[^;\s]{6,};?/i],
|
|
32
|
+
['generic-secret-assignment', /\b(?:password|passwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer|token)\b\s*[:=]\s*['"]?[A-Za-z0-9/_+=.\-]{10,}/i],
|
|
33
|
+
['env-assignment-high-entropy', /^[A-Z][A-Z0-9_]{2,}\s*=\s*\S{16,}$/m],
|
|
34
|
+
];
|
|
35
|
+
const INFRA_PATTERNS = [
|
|
36
|
+
['aws-instance-id', /\bi-[0-9a-f]{8,17}\b/],
|
|
37
|
+
['aws-sg-id', /\bsg-[0-9a-f]{8,17}\b/],
|
|
38
|
+
['aws-subnet-or-vpc-id', /\b(?:subnet|vpc|eni|vol|ami)-[0-9a-f]{8,17}\b/],
|
|
39
|
+
['aws-account-arn', /\b\d{12}\.dkr\.ecr\b|arn:aws:[^\s]*:\d{12}:/i],
|
|
40
|
+
['rds-or-cloud-endpoint', /\b[a-z0-9-]+\.[a-z0-9]+\.[a-z0-9-]+\.(?:rds|elasticache|redshift)\.amazonaws\.com\b/i],
|
|
41
|
+
['private-ipv4', /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3})\b/],
|
|
42
|
+
['private-ipv6', /\b(?:fd[0-9a-f]{2}:[0-9a-f:]{2,}|fe80:[0-9a-f:]{2,})/i],
|
|
43
|
+
['ssh-to-ip', /\bssh\b[^\n]*@\d{1,3}(?:\.\d{1,3}){3}\b/i],
|
|
44
|
+
['key-file-ref', /\b[\w-]{3,}\.(?:pem|ppk)\b|(?:\/|~\/)?\.ssh\/[\w.-]+/],
|
|
45
|
+
];
|
|
46
|
+
const PHI_PATTERNS = [
|
|
47
|
+
['ssn', /\b\d{3}[-. ]\d{2}[-. ]\d{4}\b|\bssn\b[^\n]{0,20}\b\d{9}\b/i],
|
|
48
|
+
['mrn', /\bMRN\b\s*[:#]?\s*\d{5,}/i],
|
|
49
|
+
['dob', /\b(?:DOB|date of birth)\b\s*[:#]?\s*\d/i],
|
|
50
|
+
['patient-identifier', /\bpatient\b[^.\n]{0,40}\b(?:name|ssn|mrn|dob|record\s*number)\b/i],
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Scan text for secrets + infra identifiers (always) and PHI (unless
|
|
54
|
+
* hipaa:false is explicitly passed — PHI is scanned by default).
|
|
55
|
+
*/
|
|
56
|
+
export function scanSensitive(text, opts = {}) {
|
|
57
|
+
const reasons = [];
|
|
58
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
59
|
+
return { flagged: false, reasons };
|
|
60
|
+
for (const [label, re] of SECRET_PATTERNS)
|
|
61
|
+
if (re.test(text))
|
|
62
|
+
reasons.push(label);
|
|
63
|
+
for (const [label, re] of INFRA_PATTERNS)
|
|
64
|
+
if (re.test(text))
|
|
65
|
+
reasons.push('infra:' + label);
|
|
66
|
+
if (opts.hipaa !== false)
|
|
67
|
+
for (const [label, re] of PHI_PATTERNS)
|
|
68
|
+
if (re.test(text))
|
|
69
|
+
reasons.push('phi:' + label);
|
|
70
|
+
return { flagged: reasons.length > 0, reasons };
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfinitmonkeys/clan-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Deterministic setup engine for Nfinit Monkeys clans \u2014 the single template + reconciliation authority behind the published CLIs and the internal fleet tooling. Idempotent, dry-run-by-default, non-destructive.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|