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
115 changes: 88 additions & 27 deletions .claude/hooks/enforce-writes-scope.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,11 @@ const DEV_SAFE_SET_EXTRA = [".dev/features/**", "pharn/pharn-*/**"];
const INSTALL_SAFE_SET = ["pharn/features/**"];

function isPharnInstalledProject() {
const abs = path.resolve(ROOT, "pharn.config.json");
try {
const parsed = JSON.parse(fs.readFileSync(path.resolve(ROOT, "pharn.config.json"), "utf8"));
const st = fs.lstatSync(abs);
if (!st.isFile() || st.isFIFO()) return false;
const parsed = JSON.parse(fs.readFileSync(abs, "utf8"));
return typeof parsed.skillsVersion === "string" && parsed.skillsVersion.length > 0;
} catch {
return false;
Expand Down Expand Up @@ -469,17 +472,31 @@ const SCOPE_FILE = ".pharn/writes-scope.json";
// directory or a dangling link cannot be read anyway. A plain object is carried as `record` even when its
// `scope` is not an array, so the dev/unsignalled message keeps the origin line and the stale-scope
// bullet the pre-6.24.0 message showed for that exact shape (GATE-2 review, minor 1).
function dotPharnStateBad() {
const dot = path.join(ROOT, ".pharn");
try {
const st = fs.lstatSync(dot);
if (st.isSymbolicLink()) return true;
} catch (err) {
if (err && err.code === "ENOENT") return false;
return true;
}
return false;
}

function readScopeFileState() {
if (dotPharnStateBad()) return { kind: "malformed" };
const abs = path.resolve(ROOT, SCOPE_FILE);
let lst;
try {
fs.lstatSync(abs); // PRESENCE (L54) — never existsSync: a dangling link counts as present.
lst = fs.lstatSync(abs); // PRESENCE (L54) — never existsSync: a dangling link counts as present.
} catch (e) {
if (e && e.code === "ENOENT") return { kind: "absent" };
return { kind: "malformed" };
}
if (lst.isSymbolicLink() || !lst.isFile()) return { kind: "malformed" };
let raw;
try {
if (!fs.statSync(abs).isFile()) return { kind: "malformed" };
raw = fs.readFileSync(abs, "utf8");
} catch {
return { kind: "malformed" };
Expand Down Expand Up @@ -512,6 +529,14 @@ const RUN_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
function scanRuns(root) {
const runs = [];
const scanErrorDirs = [];
try {
const dot = fs.lstatSync(path.join(root, ".pharn"));
if (dot.isSymbolicLink()) {
return { runs: [], scanErrorDirs: RUN_STATE.map((s) => s.dir) };
}
} catch (err) {
if (!err || err.code !== "ENOENT") return { runs: [], scanErrorDirs: RUN_STATE.map((s) => s.dir) };
}
for (const { dir } of RUN_STATE) {
const stateDir = path.join(root, ".pharn", dir);
let st;
Expand Down Expand Up @@ -542,8 +567,16 @@ function scanRuns(root) {
if (!scanErrorDirs.includes(dir)) scanErrorDirs.push(dir);
continue;
}
const markerFile = path.join(stateDir, name, "active.json");
const ageMs = Math.abs(Date.now() - mst.mtimeMs);
if (ageMs <= RUN_AGE_CEILING_MS) runs.push({ dir, name, ageHours: Math.floor(ageMs / 3_600_000) });
if (ageMs <= RUN_AGE_CEILING_MS) {
try {
fs.utimesSync(markerFile, new Date(), new Date());
} catch {
/* refresh is best-effort; presence+age still govern */
}
runs.push({ dir, name, ageHours: Math.floor(ageMs / 3_600_000) });
}
}
}
return { runs, scanErrorDirs };
Expand Down Expand Up @@ -904,6 +937,25 @@ function extractPaths(toolInput) {

// Tiny stdlib glob -> anchored RegExp. `**` spans segments (incl. `/`); `*` matches within one segment
// (no `/`); everything else literal. A bare path matches only itself.
function toScopeFoldKey(rel) {
return String(rel)
.replace(/\\/g, "/")
.normalize("NFC")
.split("/")
.map((s) => (s === "." || s === ".." ? s : s.replace(/[. ]+$/, "")))
.join("/")
.toUpperCase()
.toLowerCase();
}

function pathMatchesScope(rel, allowRes, allowFoldRes, foldMode = "none") {
if (allowRes.some((re) => re.test(rel))) return true;
if (foldMode === "none") return false;
if (foldMode === "root-only" && rel.includes("/")) return false;
const folded = toScopeFoldKey(rel);
return allowFoldRes.some((re) => re.test(folded));
}

function globToRegExp(glob) {
let re = "";
for (let i = 0; i < glob.length; i++) {
Expand Down Expand Up @@ -1188,21 +1240,30 @@ function denyGuardError() {
process.exit(2);
}

const payload = (() => {
try {
const parsed = JSON.parse(readStdin() || "{}");
// JSON.parse("null") returns null, JSON.parse("42") a number, JSON.parse("[]") an array — NONE of
// them throws, so the `catch` above never fires, and every one then dereferences into an uncaught
// TypeError. That exit 1 is treated as NON-BLOCKING by Claude Code, so the write PROCEEDS: a crash
// in a write-guard is a fail-OPEN bypass, which is the one failure mode this file may not have.
// Mirrors the guard `protect-trusted-paths.cjs` already carries — the two hooks run on the same
// PreToolUse payload and must not disagree about what a payload IS.
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed;
} catch {
return {};
}
})();
function denyMalformedHookInput(detail) {
const reason =
"PHARN floor — write blocked (writes-scope guard, fix #7)\n" +
"WHY: hook input is not a usable PreToolUse JSON object" +
(detail ? ` (${detail})` : "") +
" — fail-closed.\n";
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
decision: "block",
reason,
})
);
process.stderr.write(reason);
process.exit(2);
}

let payload;
try {
payload = JSON.parse(readStdin() || "{}");
} catch {
denyMalformedHookInput("invalid JSON");
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object");

const toolName = payload.tool_name || payload.toolName || "";
const toolInput = payload.tool_input || payload.toolInput || {};
Expand Down Expand Up @@ -1240,13 +1301,13 @@ if (isWrite) {
}

const ctx = { install, runs: runsInfo.runs, scanErrorDirs: runsInfo.scanErrorDirs, openWithout: false, backslash: false };
const allowRe = (mode === "scoped" ? [...ALWAYS, ...scope] : mode === "safeset" ? [...ALWAYS, ...defaultSafeSet()] : []).map(
globToRegExp
);
const scopePatterns = mode === "scoped" ? [...ALWAYS, ...scope] : mode === "safeset" ? [...ALWAYS, ...defaultSafeSet()] : [];
const allowRe = scopePatterns.map(globToRegExp);
const allowFoldRe = scopePatterns.map((g) => globToRegExp(toScopeFoldKey(g)));

// Judge ONE resolved target of payload path `p`; deny() exits, so returning means this target is allowed.
// `shown` is what the message names: the old rendering for resolution (1), `p -> rel` for (2).
const judge = (p, real, physical) => {
const judge = (p, real, physical, foldMode = "none") => {
const fromRootRaw = path.relative(ROOT, real);
const fromRoot = fromRootRaw.replace(/\\/g, "/");
const rel = relToRoot(fromRoot);
Expand Down Expand Up @@ -1281,18 +1342,18 @@ if (isWrite) {
return;
}

if (!allowRe.some((re) => re.test(rel))) {
if (!pathMatchesScope(rel, allowRe, allowFoldRe, foldMode)) {
deny(shown(rel), scope, record, "in-repo", { ...ctx, openWithout: install && !ambiguous && !isReserved(rel) });
}
};

// PASS 1 — resolution (1) over EVERY path first, so any write the pre-6.24.0 hook denied is denied here
// with the same message. PASS 2 — resolution (2), only where it reaches a different target.
const lexical = writePaths.map((p) => resolveWriteTarget(p));
writePaths.forEach((p, i) => judge(p, lexical[i], false));
const physical = writePaths.map((p) => resolvePhysicalTarget(p));
writePaths.forEach((p, i) => judge(p, lexical[i], false, physical[i] !== lexical[i] ? "alias" : "none"));
writePaths.forEach((p, i) => {
const physical = resolvePhysicalTarget(p);
if (physical !== lexical[i]) judge(p, physical, true);
if (physical[i] !== lexical[i]) judge(p, physical[i], true, "root-only");
});
} catch {
denyGuardError();
Expand Down
31 changes: 31 additions & 0 deletions .claude/hooks/enforce-writes-scope.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2879,3 +2879,34 @@ test("✧ PIN: resolvePhysicalTarget(), fsRootOf() and the three walk constants
assert.match(fn(protectSrc, "realpathOr"), /fs\.realpathSync\(p\)/);
assert.doesNotMatch(fn(protectSrc, "realpathOr"), /\.native/);
});

// --- LOW batch 1 (L5/L7/L9) ---------------------------------------------------
test("★ L7: null JSON payload denies (exit 2), never fail-open exit 0", () => {
const r = spawnSync(process.execPath, [HOOK], { input: "null", cwd: tmp(), encoding: "utf8" });
assert.equal(r.status, 2, "null payload must deny");
});

test("★ L7: invalid JSON payload denies (exit 2)", () => {
const r = spawnSync(process.execPath, [HOOK], { input: "{not json", cwd: tmp(), encoding: "utf8" });
assert.equal(r.status, 2);
});

test("★ L5: a symlinked .pharn makes install posture deny-all (malformed scope)", () => {
const cwd = seedInstalledProject(tmp());
fs.symlinkSync(join(cwd, "other-pharn"), join(cwd, ".pharn"));
fs.mkdirSync(join(cwd, "other-pharn"), { recursive: true });
const r = hook(cwd, "src/x.js");
assert.equal(r.status, 2);
assert.match(r.stderr, /malformed|denies every write/i);
});

test("★ L9: scoped write matches declared path under case fold only when the volume aliases that spelling", () => {
const cwd = seedDevRepo(tmp());
fs.mkdirSync(join(cwd, "src"), { recursive: true });
fs.writeFileSync(join(cwd, "src", "Foo.md"), "");
const aliases = fs.existsSync(join(cwd, "src", "foo.md"));
setScope(cwd, ["src/Foo.md"]);
assert.equal(hook(cwd, "src/Foo.md").status, 0, "the exact scoped spelling stays allowed");
const r = hook(cwd, "src/foo.md");
assert.equal(r.status, aliases ? 0 : 2, "case-only mismatch allows only when it reaches the scoped file");
});
24 changes: 20 additions & 4 deletions .claude/hooks/protect-trusted-paths.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -781,16 +781,32 @@ const DENY_REASONS = {
`BLOCKED by PHARN floor: ${shown} is (or resolves to) memory-bank CANON (CONSTITUTION P2 / fix #2; THREAT-MODEL.md §2 #3 — memory poisoning is silent, cumulative, and has no rollback signal). Canon is written only through the gated promotion path. FIX (pick one): • run /pharn-memory-promote (or /pharn-dev-memory-promote), which after its human accept/deny gate sets a writes-scope whose ORIGIN authorizes exactly this one canon file; • or have a human edit canon by hand, outside the agent loop. Re-scoping a build from a PLAN's \`## Files\` CANNOT authorize this write — that is the specific thing this guard refuses, deliberately.`,
};

function denyMalformedHookInput(detail) {
const reason =
"BLOCKED by PHARN floor: hook input is not a usable PreToolUse JSON object" +
(detail ? ` (${detail})` : "") +
" — fail-closed; the write is denied.";
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
decision: "block",
reason,
})
);
process.stderr.write(reason + "\n");
process.exit(2);
}

const raw = readStdin();
let payload;
try {
payload = JSON.parse(raw || "{}");
} catch {
payload = {};
denyMalformedHookInput("invalid JSON");
}
// JSON.parse("null") returns null and JSON.parse("42") a number — neither throws, and both then
// dereference into an uncaught TypeError (exit 1, non-blocking, write proceeds).
if (!payload || typeof payload !== "object" || Array.isArray(payload)) payload = {};
// JSON.parse("null") returns null and JSON.parse("42") a number — neither throws; both must deny, not
// normalize to {} (exit 0 on a write would fail OPEN).
if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object");

const toolName = payload.tool_name || payload.toolName || "";
const toolInput = payload.tool_input || payload.toolInput || {};
Expand Down
22 changes: 17 additions & 5 deletions .claude/hooks/protect-trusted-paths.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,9 @@ for (const p of TRUSTED) {
});
}

test("malformed stdin JSON is not treated as a write (allow, no crash)", () => {
test("malformed stdin JSON denies fail-closed (exit 2), never fail-open exit 0 (L7)", () => {
const r = spawnSync(process.execPath, [HOOK], { input: "{not json", encoding: "utf8" });
assert.equal(r.status, 0);
assert.equal(r.status, 2);
});

test("a non-write tool is ignored even when its input names a control file", () => {
Expand Down Expand Up @@ -544,7 +544,7 @@ for (const payload of ["null", "42", '"str"', "[1,2]", "true"]) {
// JSON.parse("null") returns null WITHOUT throwing, so the try/catch never fired and the next
// property access exited 1.
const r = spawnSync(process.execPath, [HOOK], { input: payload, encoding: "utf8" });
assert.equal(r.status, 0);
assert.equal(r.status, 2, `${payload} must deny fail-closed`);
});
}

Expand Down Expand Up @@ -793,10 +793,14 @@ test("✧ MUTANT: dropping the inode test re-opens the hard-link alias", () => {
});

test("✧ MUTANT: dropping the non-object payload guard makes a `null` payload exit 1 (fail-open)", () => {
const sb = mutantSandbox([], 'if (!payload || typeof payload !== "object" || Array.isArray(payload)) payload = {};', "");
const sb = mutantSandbox(
[],
'if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object");',
""
);
const r = spawnSync(process.execPath, [join(sb, ".claude", "hooks", "protect-trusted-paths.cjs")], { input: "null", encoding: "utf8" });
assert.equal(r.status, 1, "the mutant MUST exit 1 — a non-blocking error, i.e. the write proceeds");
assert.equal(spawnSync(process.execPath, [HOOK], { input: "null", encoding: "utf8" }).status, 0);
assert.equal(spawnSync(process.execPath, [HOOK], { input: "null", encoding: "utf8" }).status, 2, "live hook denies fail-closed");
});

// ════════════════════════════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -1052,3 +1056,11 @@ test("✧ MUTANT: switching the second pass off re-opens the backslash-named lin
fs.symlinkSync(".", join(good, "s\\x"));
assert.equal(writeIn(good, "s\\x/LIMITS.md").status, 2);
});

test("★ L7: null JSON payload denies (exit 2), never fail-open", () => {
const r = spawnSync(process.execPath, [join(__dirname, "protect-trusted-paths.cjs")], {
input: "null",
encoding: "utf8",
});
assert.equal(r.status, 2);
});
15 changes: 15 additions & 0 deletions .dev/features/low-findings-batch-1/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# PLAN — low-findings-batch-1

- spec_content_hash: (read live at build)
- applied_lessons: [L31]
- increment: LOW security/correctness batch — hook stdin fail-closed (L7), `.pharn` symlink scope blind spot (L5), run-marker project root + marker refresh (L6), scoped path case-fold matching (L9).
- layer(s): floor (`.claude/hooks/`, `pharn/floor/run-marker.mjs`)
- constitution_refs: [P0, P2, P7]

## Files

- `.claude/hooks/protect-trusted-paths.cjs` — EDIT
- `.claude/hooks/enforce-writes-scope.cjs` — EDIT
- `.claude/hooks/protect-trusted-paths.test.cjs` — EDIT
- `.claude/hooks/enforce-writes-scope.test.cjs` — EDIT
- `pharn/floor/run-marker.mjs` — EDIT
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
`npm run check:changelog` holds this file's shape; the CI step "CHANGELOG per-PR entry check" holds
each PR's diff. Details and known costs: CONTRIBUTING.md, "CHANGELOG entries". -->

## [6.31.2] - 2026-09-28

### Fixed

- 2026-09-27: **Write guards fail closed on unusable PreToolUse stdin (LOW L7).** Both hooks now exit 2 on invalid JSON or a non-object payload instead of normalizing to `{}` (fail-open). `enforce-writes-scope.cjs` also refuses to hang on a FIFO `pharn.config.json` (lstat + regular file only).
- 2026-09-27: **`.pharn` symlink and scope-file symlink read as malformed / fail-closed (LOW L5).** A symlinked `.pharn` directory no longer redirects the scope record the guard reads; scope paths that are symlinks are malformed.
- 2026-09-27: **Run markers refresh mtime while a run is open (LOW L6).** `scanRuns()` touches valid markers so a run past 24h without `--close` does not silently revert to permissive; `run-marker.mjs` resolves the project root like the hooks (not raw cwd only).
- 2026-09-27: **Scoped writes match declared paths under case fold (LOW L9).** A scope entry and payload path that differ only by letter case now allow only when that spelling aliases the declared file on the underlying volume, without widening scoped writes to unrelated case variants.

## [6.31.1] - 2026-09-27

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ model or human judgment remains advisory.
npx @pharn-dev/pharn@latest init
```

[![pharn](https://img.shields.io/badge/pharn-6.31.1-blue)](./CHANGELOG.md)
[![pharn](https://img.shields.io/badge/pharn-6.31.2-blue)](./CHANGELOG.md)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE)
[![CI](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml)
[![CodeQL](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml)
Expand Down
2 changes: 1 addition & 1 deletion SKILLS_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.31.1
6.31.2
Loading
Loading