brainclaw 1.7.4 → 1.7.5

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 CHANGED
@@ -345,6 +345,15 @@ npm run test:coverage # with coverage report
345
345
 
346
346
  For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
347
347
 
348
+ ### v1.7.5
349
+
350
+ - **Security patch (recommended upgrade)** — fixes a git command-injection / RCE
351
+ vector flagged by Socket AI: several commands interpolated a git ref (notably
352
+ one derived from the persisted session `git_sha`) into `execSync` shell
353
+ strings. All git calls now use `execFileSync` (no shell) and `git_sha` is
354
+ validated as a hex SHA. No functional change. (session-end, release-claims,
355
+ release-notes, sync)
356
+
348
357
  ### v1.7.4
349
358
 
350
359
  - **Dispatch observability + worker DX hardening** (from a real cross-project
Binary file
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import { memoryExists } from '../core/io.js';
3
3
  import { mutate } from '../core/mutation-pipeline.js';
4
4
  import { listClaims, releaseClaim } from '../core/claims.js';
@@ -23,7 +23,8 @@ export function runReleaseClaims(options = {}) {
23
23
  const ref1 = options.ref1 ?? 'ORIG_HEAD';
24
24
  const ref2 = options.ref2 ?? 'HEAD';
25
25
  try {
26
- const output = execSync(`git diff --name-only ${ref1} ${ref2}`, { encoding: 'utf-8' });
26
+ // Security: execFileSync (no shell) so ref1/ref2 cannot inject (Socket 2026-06-08 class).
27
+ const output = execFileSync('git', ['diff', '--name-only', ref1, ref2], { encoding: 'utf-8' });
27
28
  changedFiles = output.split('\n').map((f) => f.trim()).filter(Boolean);
28
29
  }
29
30
  catch {
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import { loadConfig } from '../core/config.js';
3
3
  import { memoryExists } from '../core/io.js';
4
4
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, } from '../core/brainclaw-version.js';
@@ -11,7 +11,8 @@ export function generateAgentReleaseNotes(cwd, since) {
11
11
  const baseRef = since ?? findLastVersionTag(cwd) ?? 'HEAD~20';
12
12
  let commits;
13
13
  try {
14
- const raw = execSync(`git log ${baseRef}..HEAD --oneline --no-decorate`, {
14
+ // Security: execFileSync (no shell) so baseRef cannot inject (Socket 2026-06-08 class).
15
+ const raw = execFileSync('git', ['log', `${baseRef}..HEAD`, '--oneline', '--no-decorate'], {
15
16
  cwd,
16
17
  encoding: 'utf-8',
17
18
  timeout: 10000,
@@ -58,7 +59,7 @@ export function generateAgentReleaseNotes(cwd, since) {
58
59
  }
59
60
  function findLastVersionTag(cwd) {
60
61
  try {
61
- const tag = execSync('git describe --tags --abbrev=0 HEAD', {
62
+ const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0', 'HEAD'], {
62
63
  cwd,
63
64
  encoding: 'utf-8',
64
65
  timeout: 5000,
@@ -1,5 +1,22 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import path from 'node:path';
3
+ /**
4
+ * Security (Socket alert 2026-06-08, medium): a session snapshot's `git_sha` is
5
+ * persisted state and must be treated as untrusted — it must NEVER be
6
+ * interpolated into a shell command. The fix has two layers: (1) every git call
7
+ * below uses execFileSync (NO shell, args passed literally → no metacharacter
8
+ * interpretation), and (2) git_sha is validated as a plain hex SHA before it can
9
+ * reach a git ref. Either layer alone closes the command-injection vector; both
10
+ * are kept as defense in depth.
11
+ */
12
+ export const GIT_SHA_RE = /^[0-9a-f]{7,40}$/i;
13
+ export function isValidGitSha(gitSha) {
14
+ return typeof gitSha === 'string' && GIT_SHA_RE.test(gitSha);
15
+ }
16
+ /** A trusted start ref: the snapshot SHA only if it is a valid hex SHA, else a safe literal. */
17
+ export function safeStartRef(gitSha) {
18
+ return isValidGitSha(gitSha) ? gitSha : 'HEAD~10';
19
+ }
3
20
  import { memoryExists } from '../core/io.js';
4
21
  import { buildOperationalIdentity, clearCurrentSession } from '../core/identity.js';
5
22
  import { buildContextDiff } from '../core/context-diff.js';
@@ -166,10 +183,10 @@ export async function endSession(options = {}) {
166
183
  try {
167
184
  const snapshot = loadSessionSnapshot(sessionId, options.cwd);
168
185
  const startSha = snapshot?.git_sha;
169
- const ref = startSha ?? 'HEAD~10';
186
+ const ref = safeStartRef(startSha);
170
187
  const cwd = options.cwd ?? process.cwd();
171
- const commits = execSync(`git log --oneline ${ref}..HEAD`, { encoding: 'utf-8', cwd }).trim();
172
- const diffStat = execSync(`git diff --stat ${ref}..HEAD`, { encoding: 'utf-8', cwd }).trim();
188
+ const commits = execFileSync('git', ['log', '--oneline', `${ref}..HEAD`], { encoding: 'utf-8', cwd }).trim();
189
+ const diffStat = execFileSync('git', ['diff', '--stat', `${ref}..HEAD`], { encoding: 'utf-8', cwd }).trim();
173
190
  if (commits) {
174
191
  const releasedClaims = listClaims(options.cwd)
175
192
  .filter((c) => c.status === 'released' && c.agent === registered.agent_name);
@@ -178,7 +195,7 @@ export async function endSession(options = {}) {
178
195
  let filesTouched = [];
179
196
  let fullDiff;
180
197
  try {
181
- fullDiff = execSync(`git diff ${ref}..HEAD`, { encoding: 'utf-8', cwd, maxBuffer: 10 * 1024 * 1024 }).trim();
198
+ fullDiff = execFileSync('git', ['diff', `${ref}..HEAD`], { encoding: 'utf-8', cwd, maxBuffer: 10 * 1024 * 1024 }).trim();
182
199
  filesTouched = extractFilesFromDiff(fullDiff);
183
200
  }
184
201
  catch { /* fall back to empty */ }
@@ -662,8 +679,9 @@ function countSessionEditedFiles(sessionId, cwd) {
662
679
  const repoCwd = cwd ?? process.cwd();
663
680
  try {
664
681
  const touched = new Set();
665
- if (snapshot?.git_sha) {
666
- for (const pathEntry of execSync(`git diff --name-only ${snapshot.git_sha}..HEAD`, {
682
+ const sha = snapshot?.git_sha;
683
+ if (isValidGitSha(sha)) {
684
+ for (const pathEntry of execFileSync('git', ['diff', '--name-only', `${sha}..HEAD`], {
667
685
  cwd: repoCwd,
668
686
  encoding: 'utf-8',
669
687
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -671,14 +689,14 @@ function countSessionEditedFiles(sessionId, cwd) {
671
689
  touched.add(pathEntry);
672
690
  }
673
691
  }
674
- for (const pathEntry of execSync('git diff --name-only HEAD', {
692
+ for (const pathEntry of execFileSync('git', ['diff', '--name-only', 'HEAD'], {
675
693
  cwd: repoCwd,
676
694
  encoding: 'utf-8',
677
695
  stdio: ['ignore', 'pipe', 'ignore'],
678
696
  }).split(/\r?\n/).filter((entry) => Boolean(entry) && shouldCountEditedPath(entry))) {
679
697
  touched.add(pathEntry);
680
698
  }
681
- for (const pathEntry of execSync('git ls-files --others --exclude-standard', {
699
+ for (const pathEntry of execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
682
700
  cwd: repoCwd,
683
701
  encoding: 'utf-8',
684
702
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { execSync } from 'node:child_process';
3
+ import { execFileSync } from 'node:child_process';
4
4
  import { memoryExists } from '../core/io.js';
5
5
  import { loadState } from '../core/state.js';
6
6
  import { listCandidates } from '../core/candidates.js';
@@ -63,7 +63,9 @@ export function runSync(options = {}) {
63
63
  // Check git status of .brainclaw/
64
64
  let gitStatus = '';
65
65
  try {
66
- gitStatus = execSync(`git status --porcelain ${pathSpec}`, {
66
+ // Security: execFileSync (no shell) + scopePaths spread as separate args so
67
+ // path specs cannot inject (Socket 2026-06-08 class).
68
+ gitStatus = execFileSync('git', ['status', '--porcelain', ...scopePaths], {
67
69
  encoding: 'utf-8',
68
70
  cwd: options.cwd ?? process.cwd(),
69
71
  timeout: 5000,
@@ -85,11 +87,12 @@ export function runSync(options = {}) {
85
87
  if (options.commit) {
86
88
  const msg = options.message ?? `chore: sync brainclaw (${new Date().toISOString().slice(0, 10)})`;
87
89
  try {
88
- execSync(`git add ${pathSpec}`, {
90
+ execFileSync('git', ['add', ...scopePaths], {
89
91
  cwd: options.cwd ?? process.cwd(),
90
92
  timeout: 5000,
91
93
  });
92
- execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
94
+ // commit message passed as a literal arg — no shell, no escaping needed.
95
+ execFileSync('git', ['commit', '-m', msg], {
93
96
  encoding: 'utf-8',
94
97
  cwd: options.cwd ?? process.cwd(),
95
98
  timeout: 10000,
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.7.4 on 2026-06-08T21:59:54.110Z
2
+ // Source: brainclaw v1.7.5 on 2026-06-09T05:31:24.417Z
3
3
  export const FACTS = {
4
- "version": "1.7.4",
5
- "generated_at": "2026-06-08T21:59:54.110Z",
4
+ "version": "1.7.5",
5
+ "generated_at": "2026-06-09T05:31:24.417Z",
6
6
  "tools": {
7
7
  "count": 62,
8
8
  "published_count": 61,
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.7.4",
3
- "generated_at": "2026-06-08T21:59:54.110Z",
2
+ "version": "1.7.5",
3
+ "generated_at": "2026-06-09T05:31:24.417Z",
4
4
  "tools": {
5
5
  "count": 62,
6
6
  "published_count": 61,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {