brainclaw 1.8.0 → 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.
Files changed (140) hide show
  1. package/README.md +12 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +285 -22
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +588 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +79 -5
  55. package/dist/core/dispatcher.js +64 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/facade-schema.js +38 -0
  67. package/dist/core/gc-semantic.js +130 -5
  68. package/dist/core/handoff-snapshot.js +68 -0
  69. package/dist/core/ids.js +19 -8
  70. package/dist/core/instruction-templates.js +34 -115
  71. package/dist/core/io.js +39 -3
  72. package/dist/core/json-store.js +10 -1
  73. package/dist/core/lock.js +153 -28
  74. package/dist/core/loops/bootstrap-acquire.js +25 -1
  75. package/dist/core/loops/facade-schema.js +2 -0
  76. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  77. package/dist/core/loops/index.js +1 -0
  78. package/dist/core/loops/presets/bootstrap.js +7 -0
  79. package/dist/core/loops/store.js +17 -0
  80. package/dist/core/loops/verbs.js +24 -1
  81. package/dist/core/markdown.js +8 -76
  82. package/dist/core/mcp-command-resolution.js +245 -0
  83. package/dist/core/memory-compactor.js +5 -3
  84. package/dist/core/memory-lifecycle.js +282 -0
  85. package/dist/core/merge-risk.js +150 -0
  86. package/dist/core/messaging.js +8 -1
  87. package/dist/core/migration.js +11 -1
  88. package/dist/core/observer-mode.js +26 -0
  89. package/dist/core/operations/memory-mutation.js +90 -65
  90. package/dist/core/operations/plan.js +27 -1
  91. package/dist/core/protocol-skills.js +210 -0
  92. package/dist/core/reflection-safety.js +6 -7
  93. package/dist/core/reputation.js +84 -2
  94. package/dist/core/runtime-signals.js +71 -9
  95. package/dist/core/runtime.js +84 -1
  96. package/dist/core/schema.js +114 -0
  97. package/dist/core/security-detectors.js +125 -0
  98. package/dist/core/security-extract.js +189 -0
  99. package/dist/core/security-guard.js +107 -29
  100. package/dist/core/security-packages.js +121 -0
  101. package/dist/core/security-scoring.js +76 -9
  102. package/dist/core/security.js +34 -2
  103. package/dist/core/sequence.js +11 -2
  104. package/dist/core/setup-flow.js +141 -13
  105. package/dist/core/staleness.js +72 -1
  106. package/dist/core/state.js +250 -54
  107. package/dist/core/store-resolution.js +19 -5
  108. package/dist/core/worktree.js +72 -8
  109. package/dist/facts.js +8 -8
  110. package/dist/facts.json +7 -7
  111. package/docs/PROTOCOL.md +223 -0
  112. package/docs/cli.md +11 -10
  113. package/docs/concepts/coordinator-runbook.md +129 -0
  114. package/docs/concepts/event-log-store-critique-A.md +333 -0
  115. package/docs/concepts/event-log-store-critique-B.md +353 -0
  116. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  117. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  118. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  119. package/docs/concepts/event-log-store.md +928 -0
  120. package/docs/concepts/identity-model-proposal.md +371 -0
  121. package/docs/concepts/memory.md +5 -4
  122. package/docs/concepts/observer-protocol.md +361 -0
  123. package/docs/concepts/parallel-merge-protocol.md +71 -0
  124. package/docs/concepts/plans-and-claims.md +43 -0
  125. package/docs/concepts/skills.md +78 -0
  126. package/docs/concepts/workspace-bootstrapping.md +61 -0
  127. package/docs/integrations/agents.md +4 -4
  128. package/docs/integrations/cline.md +10 -11
  129. package/docs/integrations/codex.md +2 -2
  130. package/docs/integrations/continue.md +5 -5
  131. package/docs/integrations/copilot.md +14 -12
  132. package/docs/integrations/openclaw.md +7 -6
  133. package/docs/integrations/overview.md +7 -7
  134. package/docs/integrations/roo.md +3 -3
  135. package/docs/integrations/windsurf.md +6 -6
  136. package/docs/mcp-schema-changelog.md +29 -2
  137. package/docs/quickstart.md +48 -47
  138. package/docs/security.md +174 -15
  139. package/docs/storage.md +4 -2
  140. 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 brainclaw check-security.
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 --security
9
- # Do not edit manually — regenerate with brainclaw setup --security
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) return 0 ;;
18
- -*) continue ;;
19
- *) break ;;
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
- echo "$packages"
94
+ printf '%s\\t%s' "$packages" "$req_file"
61
95
  }
62
96
 
63
97
  # Main logic
64
98
  if is_install_command "$@"; then
65
- packages=$(extract_packages "$@")
66
- if [ -n "$packages" ]; then
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
- result=$("$BRAINCLAW_BIN" check-security --packages "$packages" --ecosystem "$ecosystem" --json 2>/dev/null)
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
- echo "[brainclaw-guard] Run: brainclaw check-security --packages \\"$packages\\" for details" >&2
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
- echo "[brainclaw-guard] Run: brainclaw check-security --packages \\"$packages\\" for details" >&2
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 --security
93
- # Do not edit manually — regenerate with brainclaw setup --security
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) { $skipNext = $false; continue }
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 "^--(registry|prefix)$") { $skipNext = $true; continue }
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) { $packages += $arg }
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
- $packages = Extract-Packages $Args
124
- if ($packages) {
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 check-security --packages $packages --ecosystem $ecosystem --json 2>$null | Out-Null
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] Run: brainclaw check-security --packages \`"$packages\`" for details"
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] Run: brainclaw check-security --packages \`"$packages\`" for details"
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 = { ...DEFAULT_WEIGHTS, ...weights };
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 = { ...DEFAULT_THRESHOLDS, ...config?.thresholds };
24
- const weights = { ...DEFAULT_WEIGHTS, ...config?.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
- // Denylist check (exact match on package name)
31
- if (denylist.some(d => pkgName === d || scores.purl.includes(d))) {
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}" is on the denylist`],
105
+ reasons: [`Package "${pkgName}@${scores.version}" matches denylist entry "${denyHit.raw.trim()}"`],
40
106
  };
41
107
  }
42
- // Allowlist check (skip scoring)
43
- if (allowlist.some(a => pkgName === a || scores.purl.includes(a))) {
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}" is on the allowlist`],
118
+ reasons: [`Package "${pkgName}@${scores.version}" matches allowlist entry "${allowHit.raw.trim()}"`],
52
119
  };
53
120
  }
54
121
  const composite = computeComposite(scores, weights);