@compr/opscontext-mcp 2.9.0 → 2.9.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.9.1] 2026-09-25: a staged file name is data, never shell
10
+
11
+ ### Security
12
+
13
+ - **A staged file name is data, never shell** (`src/hooks.ts`, LOCK
14
+ `[STAGED-PATH-IS-AN-ARGUMENT-NEVER-A-SHELL-STRING]`). The pre-commit checkers ran
15
+ `git diff --cached -- "<name>"` and `git show :"<name>"` through a shell, escaping only the
16
+ double quote. Proven on 2026-09-25: a staged file named `note$(touch PROOF).md` ran `touch`
17
+ while `contextengine hook secret-scan` listed the staged files, so a cloned repo or a pull
18
+ request could run a command on the committer's machine. Names now reach git as arguments,
19
+ listed with `-z` and matched with a `:(literal)` pathspec; the rule-parity index read, whose
20
+ names come from `policy.json`, takes the same path.
21
+ - **Every staged file is scanned, whatever its name.** The same line dropped every non-ASCII
22
+ name (git prints `caf\303\251.md` quoted, the lookup failed, the file was skipped unscanned)
23
+ and a name containing `*` matched other files too. A planted key in `café.md` passed the
24
+ scanner before; it is caught now. A diff above 1 MB was skipped silently as well; the buffer
25
+ is 32 MB.
26
+
9
27
  ## [2.9.0] 2026-09-25: credentials and prompt words stay out of the audit log
10
28
 
11
29
  ### Security
package/dist/hooks.js CHANGED
@@ -43,7 +43,7 @@
43
43
  // project gets its own `.contextengine/policy.json`. This rule only
44
44
  // applies to the repo it lives in. Cross-repo enforcement is a
45
45
  // separate concern (org-policy distribution via `extends:`).
46
- import { execSync } from "child_process";
46
+ import { execFileSync } from "child_process";
47
47
  import { existsSync, readFileSync } from "fs";
48
48
  import { createHash } from "crypto";
49
49
  import { join } from "path";
@@ -107,25 +107,36 @@ export function matchesAnyGlob(path, globs) {
107
107
  * Errors from git (not a repo, no staged changes, etc.) are RE-THROWN, not
108
108
  * swallowed. The caller decides whether "no staged changes" is fatal.
109
109
  */
110
+ // [LOCKED] [STAGED-PATH-IS-AN-ARGUMENT-NEVER-A-SHELL-STRING] - 2026-09-25
111
+ // [NEVER] build a shell command string from a staged path, a policy path, or any other value a
112
+ // repository controls. Pass it as one argv element to execFileSync, list names with -z,
113
+ // and address the file with a `:(literal)` pathspec (here) or a `:path` blob spec
114
+ // (contentAfterCommit below).
115
+ // WHY: until 2.9.0 this ran `git diff --cached -- "<name>"` through a shell, escaping only the
116
+ // double quote. Proven in a scratch repo on 2026-09-25: a staged file named
117
+ // `note$(touch PROOF).md` ran `touch` while the pre-commit scanner parsed the staged list,
118
+ // so a cloned repo or a pull request could run a command on the committer's machine. The
119
+ // same line also lost every non-ASCII name: `--name-only` prints `caf\303\251.md` C-quoted,
120
+ // the shell lookup found no such file, the catch skipped it, and a planted key in `café.md`
121
+ // was never scanned. A plain pathspec is a glob too: `a*.md` also matched `ab.md`.
122
+ // FIX: execFileSync("git", [...]) with the name as its own argument (no shell exists to expand
123
+ // it), `-z` so git prints raw names, `:(literal)` so a name matches only itself, and a
124
+ // 32 MB buffer so a large file is not skipped silently. tests/hooks.test.ts and
125
+ // tests/rule-parity.test.ts stage those exact names.
110
126
  export function getStagedFiles(repoRoot) {
111
- const nameOutput = execSync("git diff --cached --name-only --diff-filter=ACMR", {
112
- cwd: repoRoot,
113
- encoding: "utf-8",
114
- }).trim();
115
- if (!nameOutput)
127
+ const nameOutput = execFileSync("git", ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], { cwd: repoRoot, encoding: "utf-8" });
128
+ const fileNames = nameOutput.split("\0").filter((f) => f.length > 0);
129
+ if (fileNames.length === 0)
116
130
  return [];
117
- const fileNames = nameOutput.split("\n");
118
131
  const result = [];
119
132
  for (const file of fileNames) {
120
133
  let diff;
121
134
  try {
122
- diff = execSync(`git diff --cached --unified=0 -- "${file.replace(/"/g, '\\"')}"`, {
123
- cwd: repoRoot,
124
- encoding: "utf-8",
125
- });
135
+ diff = execFileSync("git", ["diff", "--cached", "--unified=0", "--", `:(literal)${file}`], { cwd: repoRoot, encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 });
126
136
  }
127
137
  catch {
128
- // Binary file, deleted, or path that doesn't roundtrip — skip safely.
138
+ // git could not diff this path (index and worktree disagree in a way git refuses to
139
+ // print, or the diff exceeds the buffer): nothing to parse, skip it.
129
140
  continue;
130
141
  }
131
142
  const addedLines = [];
@@ -586,7 +597,9 @@ export function formatDocCoverageViolationsJson(violations) {
586
597
  function contentAfterCommit(repoRoot, rel, stagedSet, useWorktree) {
587
598
  if (!useWorktree && stagedSet.has(rel)) {
588
599
  try {
589
- return execSync(`git show :"${rel}"`, {
600
+ // [LOCK] [STAGED-PATH-IS-AN-ARGUMENT-NEVER-A-SHELL-STRING]: `rel` comes from policy.json,
601
+ // which a cloned repo controls; it is an argv element, never part of a shell string.
602
+ return execFileSync("git", ["show", `:${rel}`], {
590
603
  cwd: repoRoot,
591
604
  encoding: "utf-8",
592
605
  maxBuffer: 32 * 1024 * 1024,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",