brainclaw 1.7.5 → 1.9.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/README.md +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
export function collectPackages(opts) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
const out = [];
|
|
5
|
+
const push = (spec) => {
|
|
6
|
+
const trimmed = spec.trim();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return;
|
|
9
|
+
// Skip local paths and URLs — they aren't registry packages.
|
|
10
|
+
if (isLocalOrUrl(trimmed))
|
|
11
|
+
return;
|
|
12
|
+
if (seen.has(trimmed))
|
|
13
|
+
return;
|
|
14
|
+
seen.add(trimmed);
|
|
15
|
+
out.push(trimmed);
|
|
16
|
+
};
|
|
17
|
+
if (opts.packages) {
|
|
18
|
+
for (const item of opts.packages.split(','))
|
|
19
|
+
push(item);
|
|
20
|
+
}
|
|
21
|
+
if (opts.requirements) {
|
|
22
|
+
for (const item of parseRequirementsFile(opts.requirements))
|
|
23
|
+
push(item);
|
|
24
|
+
}
|
|
25
|
+
if (opts.lockfile) {
|
|
26
|
+
for (const item of parseLockfile(opts.lockfile))
|
|
27
|
+
push(item);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return true for arguments that look like a filesystem path or URL
|
|
33
|
+
* rather than a registry package name. Mirrors the heuristic the
|
|
34
|
+
* wrapper scripts use when intercepting install commands.
|
|
35
|
+
*/
|
|
36
|
+
export function isLocalOrUrl(spec) {
|
|
37
|
+
if (!spec)
|
|
38
|
+
return false;
|
|
39
|
+
if (spec === '.' || spec === '..')
|
|
40
|
+
return true;
|
|
41
|
+
if (spec.startsWith('./') || spec.startsWith('../'))
|
|
42
|
+
return true;
|
|
43
|
+
if (spec.startsWith('/'))
|
|
44
|
+
return true; // absolute POSIX path
|
|
45
|
+
if (/^[A-Za-z]:[\\/]/.test(spec))
|
|
46
|
+
return true; // Windows drive path
|
|
47
|
+
if (/^[a-z]+:\/\//.test(spec))
|
|
48
|
+
return true; // http(s)/git/file URLs
|
|
49
|
+
if (spec.startsWith('git+') || spec.startsWith('git@'))
|
|
50
|
+
return true;
|
|
51
|
+
if (spec.endsWith('.tar.gz') || spec.endsWith('.tgz') || spec.endsWith('.whl'))
|
|
52
|
+
return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse a pip requirements.txt file. Supports the subset of syntax that
|
|
57
|
+
* actually shows up in install gates:
|
|
58
|
+
* - one spec per line; "name", "name==version", "name~=version"
|
|
59
|
+
* - comments (#) and blank lines
|
|
60
|
+
* - continuation lines with trailing backslash
|
|
61
|
+
* - line options (-r/--requirement, -e/--editable, -i/--index-url, etc.)
|
|
62
|
+
* are skipped, with -r/--requirement recursively included.
|
|
63
|
+
*/
|
|
64
|
+
export function parseRequirementsFile(filePath) {
|
|
65
|
+
const raw = readFileOrThrow(filePath, 'requirements file');
|
|
66
|
+
const out = [];
|
|
67
|
+
const lines = unfoldContinuations(raw).split(/\r?\n/);
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const stripped = line.replace(/\s*#.*$/, '').trim();
|
|
70
|
+
if (!stripped)
|
|
71
|
+
continue;
|
|
72
|
+
if (stripped.startsWith('-r ') || stripped.startsWith('--requirement ')) {
|
|
73
|
+
// Recursive include — keep it bounded but useful.
|
|
74
|
+
const nested = stripped.replace(/^(-r|--requirement)\s+/, '').trim();
|
|
75
|
+
if (nested) {
|
|
76
|
+
try {
|
|
77
|
+
out.push(...parseRequirementsFile(resolveSibling(filePath, nested)));
|
|
78
|
+
}
|
|
79
|
+
catch { /* missing nested file is non-fatal */ }
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (stripped.startsWith('-'))
|
|
84
|
+
continue; // -e, -i, --index-url, etc.
|
|
85
|
+
// Drop env-marker portion: "pkg==1.0 ; python_version>'3.7'"
|
|
86
|
+
const noMarker = stripped.split(';')[0].trim();
|
|
87
|
+
if (!noMarker)
|
|
88
|
+
continue;
|
|
89
|
+
// Strip extras: "pkg[extra1,extra2]==1.0"
|
|
90
|
+
const noExtras = noMarker.replace(/\[[^\]]*\]/g, '');
|
|
91
|
+
// Keep only "name" or "name==version" forms; reject URL/path specs and
|
|
92
|
+
// ranges we cannot translate to an exact spec ("~=", ">=", "<=", "<", ">", "!=", "===").
|
|
93
|
+
const exact = noExtras.match(/^([A-Za-z0-9._-]+)\s*==\s*([^\s,;]+)\s*$/);
|
|
94
|
+
if (exact) {
|
|
95
|
+
out.push(`${exact[1]}==${exact[2]}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const bare = noExtras.match(/^([A-Za-z0-9._-]+)\s*$/);
|
|
99
|
+
if (bare) {
|
|
100
|
+
out.push(bare[1]);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// For range specs, keep the name (range resolution is outside our scope).
|
|
104
|
+
const nameOnly = noExtras.match(/^([A-Za-z0-9._-]+)/);
|
|
105
|
+
if (nameOnly) {
|
|
106
|
+
out.push(nameOnly[1]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse a lockfile to extract top-level direct-dependency package names.
|
|
113
|
+
* Supports:
|
|
114
|
+
* - npm package-lock.json (v1, v2, v3): uses `packages[""].dependencies`
|
|
115
|
+
* and `packages[""].devDependencies` when present (v2+), else falls
|
|
116
|
+
* back to top-level `dependencies` (v1).
|
|
117
|
+
* - npm shrinkwrap.json: same shape as package-lock.
|
|
118
|
+
*
|
|
119
|
+
* Transitive deps are deliberately excluded; the gate is for what the
|
|
120
|
+
* operator is asking to install, not the full resolved graph.
|
|
121
|
+
*/
|
|
122
|
+
export function parseLockfile(filePath) {
|
|
123
|
+
const raw = readFileOrThrow(filePath, 'lockfile');
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
throw new Error(`Failed to parse lockfile ${filePath}: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
if (!parsed || typeof parsed !== 'object')
|
|
132
|
+
return [];
|
|
133
|
+
const obj = parsed;
|
|
134
|
+
const out = [];
|
|
135
|
+
// npm package-lock v2+
|
|
136
|
+
const packages = obj['packages'];
|
|
137
|
+
if (packages && typeof packages === 'object') {
|
|
138
|
+
const root = packages[''];
|
|
139
|
+
if (root && typeof root === 'object') {
|
|
140
|
+
const deps = root['dependencies'];
|
|
141
|
+
const devDeps = root['devDependencies'];
|
|
142
|
+
collectLockDeps(deps, out);
|
|
143
|
+
collectLockDeps(devDeps, out);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// npm package-lock v1 fallback
|
|
147
|
+
if (out.length === 0) {
|
|
148
|
+
const deps = obj['dependencies'];
|
|
149
|
+
if (deps && typeof deps === 'object') {
|
|
150
|
+
for (const [name, meta] of Object.entries(deps)) {
|
|
151
|
+
const v = meta && typeof meta === 'object' ? meta['version'] : undefined;
|
|
152
|
+
out.push(typeof v === 'string' ? `${name}@${v}` : name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function collectLockDeps(deps, out) {
|
|
159
|
+
if (!deps || typeof deps !== 'object')
|
|
160
|
+
return;
|
|
161
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
162
|
+
if (typeof range === 'string' && /^\d/.test(range)) {
|
|
163
|
+
out.push(`${name}@${range}`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
out.push(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function unfoldContinuations(raw) {
|
|
171
|
+
return raw.replace(/\\\r?\n/g, ' ');
|
|
172
|
+
}
|
|
173
|
+
function readFileOrThrow(p, label) {
|
|
174
|
+
try {
|
|
175
|
+
return fs.readFileSync(p, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
throw new Error(`Could not read ${label} at ${p}: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function resolveSibling(parent, nested) {
|
|
182
|
+
// Tolerate both absolute and relative includes without pulling in `node:path`.
|
|
183
|
+
if (nested.startsWith('/') || /^[A-Za-z]:[\\/]/.test(nested))
|
|
184
|
+
return nested;
|
|
185
|
+
const sep = parent.includes('\\') ? '\\' : '/';
|
|
186
|
+
const dir = parent.replace(/[\\/][^\\/]*$/, '');
|
|
187
|
+
return dir + sep + nested;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=security-extract.js.map
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates brainclaw-guard wrapper scripts (bash + PowerShell) that intercept
|
|
3
|
-
* npm/pip install commands and check packages via
|
|
3
|
+
* npm/pip/pnpm/yarn install commands and check packages via
|
|
4
|
+
* brainclaw check-security.
|
|
5
|
+
*
|
|
6
|
+
* Mode is enforced by the CLI's exit code, not the wrapper:
|
|
7
|
+
* exit 0 — pass (silent)
|
|
8
|
+
* exit 1 — warn (printed; install continues)
|
|
9
|
+
* exit 2 — block (printed; install aborted)
|
|
10
|
+
* In advisory mode the CLI never emits exit 2, so the wrapper never aborts.
|
|
11
|
+
* The wrapper therefore stays mode-agnostic — flipping advisory↔enforced is
|
|
12
|
+
* a config change, no regeneration required.
|
|
4
13
|
*/
|
|
5
14
|
export function generateBashGuard(brainclawBin) {
|
|
6
15
|
return `#!/usr/bin/env bash
|
|
7
16
|
# brainclaw-guard — preinstall security gate
|
|
8
|
-
# Generated by: brainclaw setup
|
|
9
|
-
# Do not edit manually — regenerate with brainclaw setup
|
|
17
|
+
# Generated by: brainclaw setup-security
|
|
18
|
+
# Do not edit manually — regenerate with brainclaw setup-security
|
|
10
19
|
|
|
11
20
|
BRAINCLAW_BIN="${brainclawBin}"
|
|
12
21
|
ORIGINAL_CMD="\${BRAINCLAW_GUARD_ORIGINAL_CMD:-npm}"
|
|
@@ -14,21 +23,37 @@ ORIGINAL_CMD="\${BRAINCLAW_GUARD_ORIGINAL_CMD:-npm}"
|
|
|
14
23
|
is_install_command() {
|
|
15
24
|
for arg in "$@"; do
|
|
16
25
|
case "$arg" in
|
|
17
|
-
install|add|i)
|
|
18
|
-
-*)
|
|
19
|
-
*)
|
|
26
|
+
install|add|i) return 0 ;;
|
|
27
|
+
-*) continue ;;
|
|
28
|
+
*) break ;;
|
|
20
29
|
esac
|
|
21
30
|
done
|
|
22
31
|
return 1
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
is_local_or_url() {
|
|
35
|
+
case "$1" in
|
|
36
|
+
.|..|./*|../*|/*|*:/*|git+*|git@*|*.tgz|*.tar.gz|*.whl) return 0 ;;
|
|
37
|
+
esac
|
|
38
|
+
case "$1" in
|
|
39
|
+
[A-Za-z]:[\\\\/]*) return 0 ;;
|
|
40
|
+
esac
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
extract_packages() {
|
|
26
45
|
local packages=""
|
|
46
|
+
local req_file=""
|
|
27
47
|
local skip_next=false
|
|
28
48
|
local past_command=false
|
|
29
49
|
for arg in "$@"; do
|
|
30
50
|
if [ "$skip_next" = true ]; then
|
|
51
|
+
# The flag's value lands here; capture it for -r/--requirement.
|
|
52
|
+
case "$prev_flag" in
|
|
53
|
+
-r|--requirement) req_file="$arg" ;;
|
|
54
|
+
esac
|
|
31
55
|
skip_next=false
|
|
56
|
+
prev_flag=""
|
|
32
57
|
continue
|
|
33
58
|
fi
|
|
34
59
|
case "$arg" in
|
|
@@ -36,10 +61,16 @@ extract_packages() {
|
|
|
36
61
|
past_command=true
|
|
37
62
|
continue
|
|
38
63
|
;;
|
|
39
|
-
--save-dev|--save-peer|--save-optional|-D|-P|-O|-g|--global)
|
|
64
|
+
--save-dev|--save-peer|--save-optional|--save-exact|--save-prod|-D|-P|-O|-E|-S|-g|--global|--no-save|--user|--upgrade|-U|--break-system-packages|--no-deps|--pre|--force-reinstall|--ignore-installed)
|
|
40
65
|
continue
|
|
41
66
|
;;
|
|
42
|
-
--registry|--prefix)
|
|
67
|
+
--registry|--prefix|--tag|--registry-url|--cache|--index-url|--extra-index-url|--find-links|--proxy|--cert|--client-cert|--trusted-host|--target|--root|--python|--platform|--abi|--implementation)
|
|
68
|
+
prev_flag="$arg"
|
|
69
|
+
skip_next=true
|
|
70
|
+
continue
|
|
71
|
+
;;
|
|
72
|
+
-r|--requirement|-e|--editable|-c|--constraint)
|
|
73
|
+
prev_flag="$arg"
|
|
43
74
|
skip_next=true
|
|
44
75
|
continue
|
|
45
76
|
;;
|
|
@@ -48,6 +79,9 @@ extract_packages() {
|
|
|
48
79
|
;;
|
|
49
80
|
*)
|
|
50
81
|
if [ "$past_command" = true ]; then
|
|
82
|
+
if is_local_or_url "$arg"; then
|
|
83
|
+
continue
|
|
84
|
+
fi
|
|
51
85
|
if [ -n "$packages" ]; then
|
|
52
86
|
packages="$packages,$arg"
|
|
53
87
|
else
|
|
@@ -57,28 +91,37 @@ extract_packages() {
|
|
|
57
91
|
;;
|
|
58
92
|
esac
|
|
59
93
|
done
|
|
60
|
-
|
|
94
|
+
printf '%s\\t%s' "$packages" "$req_file"
|
|
61
95
|
}
|
|
62
96
|
|
|
63
97
|
# Main logic
|
|
64
98
|
if is_install_command "$@"; then
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
extracted=$(extract_packages "$@")
|
|
100
|
+
packages="\${extracted%% *}"
|
|
101
|
+
req_file="\${extracted##* }"
|
|
102
|
+
|
|
103
|
+
if [ -n "$packages" ] || [ -n "$req_file" ]; then
|
|
67
104
|
ecosystem="npm"
|
|
68
105
|
if [ "$ORIGINAL_CMD" = "pip" ] || [ "$ORIGINAL_CMD" = "pip3" ]; then
|
|
69
106
|
ecosystem="pypi"
|
|
70
107
|
fi
|
|
71
108
|
|
|
72
|
-
|
|
109
|
+
args=(check-security --ecosystem "$ecosystem" --json)
|
|
110
|
+
if [ -n "$packages" ]; then args+=(--packages "$packages"); fi
|
|
111
|
+
if [ -n "$req_file" ]; then args+=(--requirements "$req_file"); fi
|
|
112
|
+
|
|
113
|
+
"$BRAINCLAW_BIN" "\${args[@]}" >/dev/null 2>&1
|
|
73
114
|
exit_code=$?
|
|
74
115
|
|
|
75
116
|
if [ $exit_code -eq 2 ]; then
|
|
76
|
-
echo "[brainclaw-guard] BLOCKED — supply chain risk detected" >&2
|
|
77
|
-
|
|
117
|
+
echo "[brainclaw-guard] BLOCKED — supply chain risk detected (enforced mode)" >&2
|
|
118
|
+
details_pkgs="\${packages:-$req_file}"
|
|
119
|
+
echo "[brainclaw-guard] Details: $BRAINCLAW_BIN check-security --ecosystem $ecosystem --packages \\"$details_pkgs\\"" >&2
|
|
78
120
|
exit 1
|
|
79
121
|
elif [ $exit_code -eq 1 ]; then
|
|
80
|
-
echo "[brainclaw-guard] WARNING — potential supply chain risk" >&2
|
|
81
|
-
|
|
122
|
+
echo "[brainclaw-guard] WARNING — potential supply chain risk (advisory or warn-threshold verdict)" >&2
|
|
123
|
+
details_pkgs="\${packages:-$req_file}"
|
|
124
|
+
echo "[brainclaw-guard] Details: $BRAINCLAW_BIN check-security --ecosystem $ecosystem --packages \\"$details_pkgs\\"" >&2
|
|
82
125
|
fi
|
|
83
126
|
# exit_code 0 = pass, continue silently
|
|
84
127
|
fi
|
|
@@ -89,8 +132,8 @@ exec "$ORIGINAL_CMD" "$@"
|
|
|
89
132
|
}
|
|
90
133
|
export function generatePowerShellGuard(brainclawBin) {
|
|
91
134
|
return `# brainclaw-guard — preinstall security gate (PowerShell)
|
|
92
|
-
# Generated by: brainclaw setup
|
|
93
|
-
# Do not edit manually — regenerate with brainclaw setup
|
|
135
|
+
# Generated by: brainclaw setup-security
|
|
136
|
+
# Do not edit manually — regenerate with brainclaw setup-security
|
|
94
137
|
|
|
95
138
|
param([Parameter(ValueFromRemainingArguments)]$Args)
|
|
96
139
|
|
|
@@ -105,45 +148,80 @@ function Is-InstallCommand($arguments) {
|
|
|
105
148
|
return $false
|
|
106
149
|
}
|
|
107
150
|
|
|
151
|
+
function Is-LocalOrUrl($arg) {
|
|
152
|
+
if ($arg -eq "." -or $arg -eq "..") { return $true }
|
|
153
|
+
if ($arg -match "^(\\./|\\.\\./|/)") { return $true }
|
|
154
|
+
if ($arg -match "^[A-Za-z]:[\\\\/]") { return $true }
|
|
155
|
+
if ($arg -match "^[a-z]+://") { return $true }
|
|
156
|
+
if ($arg -match "^(git\\+|git@)") { return $true }
|
|
157
|
+
if ($arg -match "\\.(tgz|tar\\.gz|whl)$") { return $true }
|
|
158
|
+
return $false
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
function Extract-Packages($arguments) {
|
|
109
162
|
$packages = @()
|
|
163
|
+
$reqFile = ""
|
|
110
164
|
$pastCommand = $false
|
|
111
165
|
$skipNext = $false
|
|
166
|
+
$prevFlag = ""
|
|
112
167
|
foreach ($arg in $arguments) {
|
|
113
|
-
if ($skipNext) {
|
|
168
|
+
if ($skipNext) {
|
|
169
|
+
if ($prevFlag -match "^(-r|--requirement)$") { $reqFile = $arg }
|
|
170
|
+
$skipNext = $false
|
|
171
|
+
$prevFlag = ""
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
114
174
|
if ($arg -match "^(install|add|i)$") { $pastCommand = $true; continue }
|
|
115
|
-
if ($arg -match "
|
|
175
|
+
if ($arg -match "^(--save-dev|--save-peer|--save-optional|--save-exact|--save-prod|-D|-P|-O|-E|-S|-g|--global|--no-save|--user|--upgrade|-U|--break-system-packages|--no-deps|--pre|--force-reinstall|--ignore-installed)$") { continue }
|
|
176
|
+
if ($arg -match "^(--registry|--prefix|--tag|--registry-url|--cache|--index-url|--extra-index-url|--find-links|--proxy|--cert|--client-cert|--trusted-host|--target|--root|--python|--platform|--abi|--implementation)$") {
|
|
177
|
+
$prevFlag = $arg; $skipNext = $true; continue
|
|
178
|
+
}
|
|
179
|
+
if ($arg -match "^(-r|--requirement|-e|--editable|-c|--constraint)$") {
|
|
180
|
+
$prevFlag = $arg; $skipNext = $true; continue
|
|
181
|
+
}
|
|
116
182
|
if ($arg -match "^-") { continue }
|
|
117
|
-
if ($pastCommand) {
|
|
183
|
+
if ($pastCommand) {
|
|
184
|
+
if (Is-LocalOrUrl $arg) { continue }
|
|
185
|
+
$packages += $arg
|
|
186
|
+
}
|
|
118
187
|
}
|
|
119
|
-
return ($packages -join ",")
|
|
188
|
+
return [PSCustomObject]@{ Packages = ($packages -join ","); RequirementsFile = $reqFile }
|
|
120
189
|
}
|
|
121
190
|
|
|
122
191
|
if (Is-InstallCommand $Args) {
|
|
123
|
-
$
|
|
124
|
-
|
|
192
|
+
$extracted = Extract-Packages $Args
|
|
193
|
+
$packages = $extracted.Packages
|
|
194
|
+
$reqFile = $extracted.RequirementsFile
|
|
195
|
+
|
|
196
|
+
if ($packages -or $reqFile) {
|
|
125
197
|
$ecosystem = "npm"
|
|
126
198
|
if ($OriginalCmd -match "^pip") { $ecosystem = "pypi" }
|
|
127
199
|
|
|
200
|
+
$cliArgs = @("check-security", "--ecosystem", $ecosystem, "--json")
|
|
201
|
+
if ($packages) { $cliArgs += @("--packages", $packages) }
|
|
202
|
+
if ($reqFile) { $cliArgs += @("--requirements", $reqFile) }
|
|
203
|
+
|
|
128
204
|
try {
|
|
129
|
-
& $BrainclawBin
|
|
205
|
+
& $BrainclawBin @cliArgs *> $null
|
|
130
206
|
$exitCode = $LASTEXITCODE
|
|
131
207
|
} catch {
|
|
132
208
|
$exitCode = 0
|
|
133
209
|
}
|
|
134
210
|
|
|
211
|
+
$detailsPkgs = if ($packages) { $packages } else { $reqFile }
|
|
135
212
|
if ($exitCode -eq 2) {
|
|
136
|
-
Write-Error "[brainclaw-guard] BLOCKED - supply chain risk detected"
|
|
137
|
-
Write-Error "[brainclaw-guard]
|
|
213
|
+
Write-Error "[brainclaw-guard] BLOCKED - supply chain risk detected (enforced mode)"
|
|
214
|
+
Write-Error "[brainclaw-guard] Details: $BrainclawBin check-security --ecosystem $ecosystem --packages \`"$detailsPkgs\`""
|
|
138
215
|
exit 1
|
|
139
216
|
} elseif ($exitCode -eq 1) {
|
|
140
|
-
Write-Warning "[brainclaw-guard] WARNING - potential supply chain risk"
|
|
141
|
-
Write-Warning "[brainclaw-guard]
|
|
217
|
+
Write-Warning "[brainclaw-guard] WARNING - potential supply chain risk (advisory or warn-threshold verdict)"
|
|
218
|
+
Write-Warning "[brainclaw-guard] Details: $BrainclawBin check-security --ecosystem $ecosystem --packages \`"$detailsPkgs\`""
|
|
142
219
|
}
|
|
143
220
|
}
|
|
144
221
|
}
|
|
145
222
|
|
|
146
223
|
& $OriginalCmd @Args
|
|
224
|
+
exit $LASTEXITCODE
|
|
147
225
|
`;
|
|
148
226
|
}
|
|
149
227
|
export function generatePipBashGuard(brainclawBin) {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical package matching for security allow/deny lists.
|
|
3
|
+
*
|
|
4
|
+
* Supports three entry forms:
|
|
5
|
+
* "name" — bare name; matches any ecosystem
|
|
6
|
+
* "ecosystem:name" — ecosystem-scoped, any version
|
|
7
|
+
* "ecosystem:name@version" — exact (ecosystem, name, version) pin
|
|
8
|
+
*
|
|
9
|
+
* Matching is exact on each component (no substring). Bare names match by
|
|
10
|
+
* package name only, which preserves backward compatibility with the MVP
|
|
11
|
+
* config while making the comparison precise instead of "purl.includes(d)".
|
|
12
|
+
*/
|
|
13
|
+
const ECOSYSTEMS = new Set(['npm', 'pypi']);
|
|
14
|
+
/**
|
|
15
|
+
* Parse a package spec like "pkg", "pkg@1.0.0", "@scope/pkg",
|
|
16
|
+
* "@scope/pkg@1.0.0", or pip-style "pkg==1.0.0".
|
|
17
|
+
*/
|
|
18
|
+
export function parsePackageSpec(spec) {
|
|
19
|
+
const trimmed = spec.trim();
|
|
20
|
+
// pip-style "name==version"
|
|
21
|
+
const pipMatch = trimmed.match(/^([A-Za-z0-9._-]+)\s*==\s*([^\s,;]+)$/);
|
|
22
|
+
if (pipMatch) {
|
|
23
|
+
return { depname: pipMatch[1], version: pipMatch[2] };
|
|
24
|
+
}
|
|
25
|
+
// npm scoped: @scope/pkg or @scope/pkg@version
|
|
26
|
+
if (trimmed.startsWith('@')) {
|
|
27
|
+
const lastAt = trimmed.lastIndexOf('@');
|
|
28
|
+
if (lastAt > 0) {
|
|
29
|
+
return { depname: trimmed.slice(0, lastAt), version: trimmed.slice(lastAt + 1) || 'latest' };
|
|
30
|
+
}
|
|
31
|
+
return { depname: trimmed, version: 'latest' };
|
|
32
|
+
}
|
|
33
|
+
// pkg@version
|
|
34
|
+
if (trimmed.includes('@')) {
|
|
35
|
+
const idx = trimmed.indexOf('@');
|
|
36
|
+
return { depname: trimmed.slice(0, idx), version: trimmed.slice(idx + 1) || 'latest' };
|
|
37
|
+
}
|
|
38
|
+
return { depname: trimmed, version: 'latest' };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse an allow/deny list entry. Accepts:
|
|
42
|
+
* "name"
|
|
43
|
+
* "ecosystem:name"
|
|
44
|
+
* "ecosystem:name@version"
|
|
45
|
+
* Whitespace tolerated. Unknown ecosystem prefixes are treated as bare
|
|
46
|
+
* names (so "lodash@1.0.0" still works as bare-name+version).
|
|
47
|
+
*/
|
|
48
|
+
export function parseListEntry(entry) {
|
|
49
|
+
const raw = entry;
|
|
50
|
+
const trimmed = entry.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
return { ecosystem: null, name: '', version: null, raw };
|
|
53
|
+
}
|
|
54
|
+
let ecosystem = null;
|
|
55
|
+
let remainder = trimmed;
|
|
56
|
+
// Only treat "<prefix>:" as ecosystem when <prefix> is a known ecosystem.
|
|
57
|
+
// This prevents misparsing of names that legitimately contain ":".
|
|
58
|
+
const colonIdx = trimmed.indexOf(':');
|
|
59
|
+
if (colonIdx > 0) {
|
|
60
|
+
const prefix = trimmed.slice(0, colonIdx).toLowerCase();
|
|
61
|
+
if (ECOSYSTEMS.has(prefix)) {
|
|
62
|
+
ecosystem = prefix;
|
|
63
|
+
remainder = trimmed.slice(colonIdx + 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Now extract optional @version from remainder. Handle scoped names
|
|
67
|
+
// ("@scope/pkg") and pip "name==version" too.
|
|
68
|
+
const pipMatch = remainder.match(/^([A-Za-z0-9._-]+)\s*==\s*([^\s,;]+)$/);
|
|
69
|
+
if (pipMatch) {
|
|
70
|
+
return { ecosystem, name: pipMatch[1], version: pipMatch[2], raw };
|
|
71
|
+
}
|
|
72
|
+
if (remainder.startsWith('@')) {
|
|
73
|
+
const lastAt = remainder.lastIndexOf('@');
|
|
74
|
+
if (lastAt > 0) {
|
|
75
|
+
return {
|
|
76
|
+
ecosystem,
|
|
77
|
+
name: remainder.slice(0, lastAt),
|
|
78
|
+
version: remainder.slice(lastAt + 1) || null,
|
|
79
|
+
raw,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ecosystem, name: remainder, version: null, raw };
|
|
83
|
+
}
|
|
84
|
+
if (remainder.includes('@')) {
|
|
85
|
+
const idx = remainder.indexOf('@');
|
|
86
|
+
return {
|
|
87
|
+
ecosystem,
|
|
88
|
+
name: remainder.slice(0, idx),
|
|
89
|
+
version: remainder.slice(idx + 1) || null,
|
|
90
|
+
raw,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ecosystem, name: remainder, version: null, raw };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns true if (ecosystem, name, version) matches the parsed entry.
|
|
97
|
+
* Matching rules:
|
|
98
|
+
* - entry.ecosystem null → any ecosystem
|
|
99
|
+
* - entry.name must equal name exactly
|
|
100
|
+
* - entry.version null → any version; otherwise exact equality with
|
|
101
|
+
* "*" as a wildcard alias for "any version"
|
|
102
|
+
*/
|
|
103
|
+
export function matchesEntry(entry, ecosystem, name, version) {
|
|
104
|
+
if (entry.name === '')
|
|
105
|
+
return false;
|
|
106
|
+
if (entry.name !== name)
|
|
107
|
+
return false;
|
|
108
|
+
if (entry.ecosystem !== null && entry.ecosystem !== ecosystem)
|
|
109
|
+
return false;
|
|
110
|
+
if (entry.version !== null && entry.version !== '*' && entry.version !== version)
|
|
111
|
+
return false;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
export function matchesAnyEntry(entries, ecosystem, name, version) {
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
if (matchesEntry(e, ecosystem, name, version))
|
|
117
|
+
return e;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=security-packages.js.map
|
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
import { matchesAnyEntry, parseListEntry } from './security-packages.js';
|
|
2
|
+
/**
|
|
3
|
+
* Map an intrinsic verdict (pass/warn/block) to the effective decision under
|
|
4
|
+
* a given mode. In advisory mode, a block is downgraded to warn so the
|
|
5
|
+
* operator sees the issue but the wrapper does not abort the install.
|
|
6
|
+
*/
|
|
7
|
+
export function applyMode(decision, mode) {
|
|
8
|
+
if (mode === 'enforced')
|
|
9
|
+
return decision;
|
|
10
|
+
return decision === 'block' ? 'warn' : decision;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Map an effective decision to a CLI exit code.
|
|
14
|
+
* pass -> 0
|
|
15
|
+
* warn -> 1 (wrapper continues, but surfaces the warning)
|
|
16
|
+
* block -> 2 (wrapper aborts the install)
|
|
17
|
+
*/
|
|
18
|
+
export function decisionExitCode(decision) {
|
|
19
|
+
if (decision === 'block')
|
|
20
|
+
return 2;
|
|
21
|
+
if (decision === 'warn')
|
|
22
|
+
return 1;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
1
25
|
const DEFAULT_WEIGHTS = {
|
|
2
26
|
supply_chain: 0.35,
|
|
3
27
|
vulnerability: 0.30,
|
|
@@ -11,8 +35,47 @@ const DEFAULT_THRESHOLDS = {
|
|
|
11
35
|
supply_chain_block: 30,
|
|
12
36
|
vulnerability_block: 20,
|
|
13
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Normalize weights so they sum to 1.0. Without this, custom configs like
|
|
40
|
+
* `{ supply_chain: 1, vulnerability: 1 }` produce a composite that can
|
|
41
|
+
* exceed 100, making thresholds meaningless. If all weights are zero the
|
|
42
|
+
* defaults are used (degenerate config — fail open to the project default).
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeWeights(weights) {
|
|
45
|
+
const merged = { ...DEFAULT_WEIGHTS, ...weights };
|
|
46
|
+
const sum = merged.supply_chain + merged.vulnerability + merged.quality + merged.maintenance + merged.license;
|
|
47
|
+
if (sum <= 0)
|
|
48
|
+
return { ...DEFAULT_WEIGHTS };
|
|
49
|
+
if (Math.abs(sum - 1) < 1e-9)
|
|
50
|
+
return merged;
|
|
51
|
+
return {
|
|
52
|
+
supply_chain: merged.supply_chain / sum,
|
|
53
|
+
vulnerability: merged.vulnerability / sum,
|
|
54
|
+
quality: merged.quality / sum,
|
|
55
|
+
maintenance: merged.maintenance / sum,
|
|
56
|
+
license: merged.license / sum,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Clamp thresholds to the [0,100] band and enforce the invariant
|
|
61
|
+
* `composite_warn <= composite_pass`. The CLI loader can call this so a
|
|
62
|
+
* mis-configured YAML never produces a "composite=60 → block when
|
|
63
|
+
* pass=50, warn=80" non-monotonic verdict.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeThresholds(thresholds) {
|
|
66
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
67
|
+
const clamp = (n) => Math.max(0, Math.min(100, n));
|
|
68
|
+
t.composite_pass = clamp(t.composite_pass);
|
|
69
|
+
t.composite_warn = clamp(t.composite_warn);
|
|
70
|
+
t.supply_chain_block = clamp(t.supply_chain_block);
|
|
71
|
+
t.vulnerability_block = clamp(t.vulnerability_block);
|
|
72
|
+
if (t.composite_warn > t.composite_pass) {
|
|
73
|
+
t.composite_warn = t.composite_pass;
|
|
74
|
+
}
|
|
75
|
+
return t;
|
|
76
|
+
}
|
|
14
77
|
export function computeComposite(scores, weights) {
|
|
15
|
-
const w =
|
|
78
|
+
const w = normalizeWeights(weights);
|
|
16
79
|
return Math.round((scores.supplyChain * w.supply_chain +
|
|
17
80
|
scores.vulnerability * w.vulnerability +
|
|
18
81
|
scores.quality * w.quality +
|
|
@@ -20,15 +83,18 @@ export function computeComposite(scores, weights) {
|
|
|
20
83
|
scores.license * w.license) * 10) / 10;
|
|
21
84
|
}
|
|
22
85
|
export function evaluatePackage(scores, config) {
|
|
23
|
-
const thresholds =
|
|
24
|
-
const weights =
|
|
86
|
+
const thresholds = normalizeThresholds(config?.thresholds);
|
|
87
|
+
const weights = normalizeWeights(config?.weights);
|
|
25
88
|
const allowlist = config?.allowlist ?? [];
|
|
26
89
|
const denylist = config?.denylist ?? [];
|
|
27
90
|
const pkgName = scores.purl.replace(/^pkg:\w+\//, '');
|
|
28
91
|
const ecosystem = scores.purl.startsWith('pkg:pypi') ? 'pypi' : 'npm';
|
|
29
92
|
const reasons = [];
|
|
30
|
-
|
|
31
|
-
|
|
93
|
+
const parsedDeny = denylist.map(parseListEntry);
|
|
94
|
+
const parsedAllow = allowlist.map(parseListEntry);
|
|
95
|
+
// Denylist check — canonical (ecosystem, name, optional version) match.
|
|
96
|
+
const denyHit = matchesAnyEntry(parsedDeny, ecosystem, pkgName, scores.version);
|
|
97
|
+
if (denyHit) {
|
|
32
98
|
return {
|
|
33
99
|
package: pkgName,
|
|
34
100
|
ecosystem,
|
|
@@ -36,11 +102,12 @@ export function evaluatePackage(scores, config) {
|
|
|
36
102
|
scores,
|
|
37
103
|
composite: 0,
|
|
38
104
|
decision: 'block',
|
|
39
|
-
reasons: [`Package "${pkgName}"
|
|
105
|
+
reasons: [`Package "${pkgName}@${scores.version}" matches denylist entry "${denyHit.raw.trim()}"`],
|
|
40
106
|
};
|
|
41
107
|
}
|
|
42
|
-
// Allowlist check
|
|
43
|
-
|
|
108
|
+
// Allowlist check — canonical match; skips scoring.
|
|
109
|
+
const allowHit = matchesAnyEntry(parsedAllow, ecosystem, pkgName, scores.version);
|
|
110
|
+
if (allowHit) {
|
|
44
111
|
return {
|
|
45
112
|
package: pkgName,
|
|
46
113
|
ecosystem,
|
|
@@ -48,7 +115,7 @@ export function evaluatePackage(scores, config) {
|
|
|
48
115
|
scores,
|
|
49
116
|
composite: 100,
|
|
50
117
|
decision: 'pass',
|
|
51
|
-
reasons: [`Package "${pkgName}"
|
|
118
|
+
reasons: [`Package "${pkgName}@${scores.version}" matches allowlist entry "${allowHit.raw.trim()}"`],
|
|
52
119
|
};
|
|
53
120
|
}
|
|
54
121
|
const composite = computeComposite(scores, weights);
|