Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,8 @@ the same entry shape).
| 2026-05-17 | Pin subprocess uses allowlisted env, not process.env passthrough | Step 5 F4.1 — full env would leak `AWS_*` / `GITHUB_TOKEN` / `OPENAI_API_KEY` to a just-installed server's init handler. Security regression vs current `mcpm install` (which doesn't execute the server at all). |
| 2026-05-17 | `accept-drift` requires explicit `--new-hash sha256:...` | Step 6 F5 — setting `current_hash: null` created an unbounded "accept anything next" window an attacker could race into. User copies hash from block-message remediation. |
| 2026-05-17 | applyPolicy: MAX action across remaining findings (not single downgrade var) | Step 7 F1 CRITICAL — original implementation let `log_only` override on ANY one finding silently downgrade `block` from unrelated critical findings. Dedicated regression suite in `apply-policy.test.ts`. |
| 2026-05-17 | Integrity sidecars on both pins.json AND guard-policy.yaml | Step 7 F4 — a malicious npm postinstall script could otherwise silently mute every signature. Sidecar protects against same-machine tampering (postinstall scripts, malware); not anti-same-user-malware. |
| 2026-05-17 | Integrity sidecars on both pins.json AND guard-policy.yaml | Step 7 F4 — a stale/naive edit of these files would otherwise go unnoticed. Sidecar is an UNKEYED SHA-256 stored beside the file with the same perms: it provides INTEGRITY (tamper-evidence vs accidental corruption / cross-machine copies / a different OS-user), NOT authenticity vs a same-user/postinstall attacker, who can recompute the sidecar to match. See revised scope 2026-06-02 (issue #19). |
| 2026-06-02 | Integrity sidecars relabeled integrity-not-authenticity (NOT anti-malware) | Security issue #19 — the 2026-05-17 row + code comments wrongly implied the unkeyed SHA-256 sidecars stop a malicious npm `postinstall` / same-user process. They don't: any process that can write `pins.json` / `guard-policy.yaml` can recompute and rewrite the sidecar (no attacker/writer asymmetry). A keyed scheme (HMAC/signature) needs a secret the writable store lacks — same constraint as the secret store (issue #15) — so the honest fix is relabel-only (docs + comments, no behavior change); true authenticity (OS keychain / signed releases) deferred. `docs/GUARD.md` + `docs/POLICY.md` already stated this correctly; this reconciles `pins.ts`, `policy.ts`, and the Decisions Log. |
| 2026-05-17 | Zod-validated YAML parse with `.catch({})` fallback | Step 7 F2 — `paused_until: 99999999999999` (numeric, not ISO string) would otherwise bypass all inspection because `new Date(numeric)` is year 5138. Fall back to empty policy on any structural mismatch. |
| 2026-05-17 | Same-session "first hash seen" cache | Step 6 F3 — closes the double-`tools/list` bypass where a malicious server delivers benign-then-poisoned schemas before the off-thread pin write commits. |
| 2026-05-17 | FP-rate threshold 2%; effective floor 4% on the 24-message seed | Step 9 — the threshold becomes meaningful at corpus sizes ≥ 50. Documented inline in `fp-rate.test.ts`. Full 20-server capture is TODOS #29. |
Expand Down
30 changes: 30 additions & 0 deletions src/guard/__tests__/pins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { describe, expect, test, beforeEach, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
hashToolDefinition,
emptyPinsFile,
Expand Down Expand Up @@ -202,3 +203,32 @@ describe("readPins / writePins", () => {
await expect(readPins()).rejects.toThrow(/format_version mismatch/);
});
});

// Issue #19: the unkeyed SHA-256 sidecars must be documented as integrity
// (tamper-evidence), NOT authenticity vs a same-user/postinstall attacker, who
// can recompute the sidecar to match. These tests pin the honest wording in the
// source comments so the overclaim cannot silently return. Docs-only contract.
describe("issue #19 — integrity sidecars relabeled integrity-not-authenticity", () => {
const here = path.dirname(fileURLToPath(import.meta.url));
const readSrc = (rel: string) => readFileSync(path.join(here, rel), "utf-8");

test("pins.ts states integrity-not-authenticity and references issue #19", () => {
const src = readSrc("../pins.ts");
expect(src).toMatch(/issue #19/i);
expect(src).toMatch(/UNKEYED SHA-256/);
expect(src).toMatch(/NOT AUTHENTICITY|not a keyed MAC/);
// The pre-fix comment claimed it "lets us detect tampering by another
// local process" with no caveat — that overclaim must be gone.
expect(src).not.toMatch(/lets us detect tampering\s*\n?\s*\* by another local process\./);
});

test("policy.ts states integrity-not-authenticity and references issue #19", () => {
const src = readSrc("../policy.ts");
expect(src).toMatch(/issue #19/i);
expect(src).toMatch(/UNKEYED SHA-256/);
expect(src).toMatch(/NOT\s*\n?\s*\/\/\s*AUTHENTICITY|not authenticity/i);
// The pre-fix comment claimed a malicious postinstall "silently disables
// guard signatures" with no caveat that it can also rewrite the sidecar.
expect(src).not.toMatch(/script that mutates this file silently disables guard/);
});
});
18 changes: 15 additions & 3 deletions src/guard/pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@
* ~/.mcpm/pins.json — pin data, JSON, format_version-tagged
* ~/.mcpm/pins.json.integrity — SHA-256 of pins.json contents (sidecar)
*
* The integrity sidecar (security review F4.2) lets us detect tampering
* by another local process. Any mismatch on read refuses to use the
* pin file until the user runs `mcpm guard reset-integrity`.
* The integrity sidecar (security review F4.2) is an UNKEYED SHA-256 of
* pins.json stored next to it with the same 0o600 perms. It provides
* INTEGRITY (tamper-EVIDENCE against accidental corruption / cross-machine
* copies / a different OS-user account), NOT AUTHENTICITY against a
* same-user/postinstall attacker: any process that can write pins.json can
* also recompute and rewrite this sidecar to match, so there is no
* attacker/writer asymmetry. A keyed scheme (HMAC/signature) would need a
* secret the writable store lacks — same constraint as the secret store
* (security issue #15); deferred to OS-keychain support. See security issue
* #19. Any mismatch on read refuses to use the pin file until the user runs
* `mcpm guard reset-integrity`.
*
* Two-target scope: install-time capture writes captured_via:"install".
* If install-time spawn fails (OAuth, network), a placeholder entry with
Expand Down Expand Up @@ -99,6 +107,10 @@ export class PinsIntegrityError extends Error {
}
}

// Issue #19: UNKEYED SHA-256. This is an integrity checksum (tamper-evidence),
// not a keyed MAC — it cannot authenticate the writer. A same-user/postinstall
// process can recompute this to match a malicious edit. Do not document it as
// anti-malware. A keyed scheme needs a secret the writable store lacks (#15).
function fileSha(content: string): string {
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
}
Expand Down
19 changes: 14 additions & 5 deletions src/guard/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ async function integrityPath(): Promise<string> {
return path.join(await getStorePath(), POLICY_INTEGRITY_FILENAME);
}

// Issue #19: UNKEYED SHA-256 — integrity (tamper-evidence), not authenticity.
// A same-user/postinstall process can recompute this to match a malicious
// edit, so it is not anti-malware. A keyed MAC needs a secret absent here (#15).
function fileSha(content: string): string {
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
}
Expand Down Expand Up @@ -92,11 +95,17 @@ export async function readPolicy(): Promise<GuardPolicyFile> {
}
if (raw.trim() === "") return {};

// SECURITY F4: integrity sidecar parity with pins.json. A malicious
// postinstall script that mutates this file silently disables guard
// signatures (the relay reads at every session start). If a sidecar
// exists and doesn't match, refuse to use the policy until the user
// reviews + runs `mcpm guard reset-integrity --policy`.
// SECURITY F4 / issue #19: integrity sidecar parity with pins.json. This is
// an UNKEYED SHA-256 — it provides INTEGRITY (tamper-evidence vs accidental
// corruption / cross-machine copies / a different OS-user account), NOT
// AUTHENTICITY vs a same-user/postinstall attacker. A malicious postinstall
// script (running as this user) that mutates the policy can also recompute
// and rewrite this sidecar to match, so the mismatch check does NOT stop it.
// What it does catch: a naive edit that leaves the sidecar stale. A keyed
// scheme (HMAC/signature) would need a secret the writable store lacks (same
// constraint as the secret store, issue #15) — deferred to OS-keychain.
// On mismatch, refuse to use the policy until the user reviews + runs
// `mcpm guard reset-integrity --policy`.
let sidecar: string | null = null;
try {
sidecar = (await readFile(sidecarP, "utf-8")).trim();
Expand Down
Loading