@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 +18 -0
- package/dist/hooks.js +26 -13
- package/package.json +1 -1
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 {
|
|
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 =
|
|
112
|
-
|
|
113
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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",
|