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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ Adding support for a new Coding Agent host starts with
- Keep `docs/specs/*.md` titles human-readable. Do not put Story ids, status, or review state in titles, and do not use YAML front matter by default.
- Put traceability metadata in the body: `Spec ID`, optional `Story`, and `Status` in a short `## Traceability` section.

## Change Scope

- Do not proactively edit `CHANGELOG.md`, release notes, version files, roadmap/status documents, or other task-external
project metadata. Change them only when the user explicitly requests it or a pre-existing issue, spec, or acceptance
criterion requires it; user-visible behavior alone is not authorization.

## Test and Verify

- Design scripts and code for AI-friendly automated use, and validate automation with an AI agent when relevant,
Expand Down
66 changes: 66 additions & 0 deletions docs/specs/2026-07-31-security-reliability-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Harden local analysis and mutation boundaries

## Traceability

- Spec ID: security-reliability-boundaries
- Status: Implemented

## Intent

Restore a trustworthy local execution baseline for Better Harness by fixing the confirmed CLI entrypoint regression and closing three confirmed security/reliability gaps in source mutation, secret scanning, and session-text redaction. The outcome is that supported paths execute deterministically, authorized source and backup writes cannot escape through symbolic links, incomplete secret scans cannot report success, and review packets do not retain URL userinfo credentials. Keep task-external release metadata out of agent-authored changes unless a maintainer or pre-existing requirement authorizes it.

## Acceptance Scenarios

- AC-1: Direct and delegated `cloc --json` execution produces parser-safe JSON when the installation, repository, or target path contains spaces or is reached through an equivalent symbolic path.
- AC-2: A Checkup source patch rejects a source or backup path reached through a symbolic-link component or resolving outside its authorized root; ordinary in-root files and backups remain writable.
- AC-3: Secret Scan reports machine-readable incomplete coverage and returns a distinct non-zero exit code when an explicit target, nested symbolic link, or safety-sensitive file read cannot be inspected; a complete clean scan still exits zero, while secret findings preserve their existing finding exit behavior.
- AC-4: Session/review text redaction removes GitLab tokens and userinfo credentials from hierarchical URLs across HTTP, database, SSH, percent-encoded, and username/token-only forms.
- AC-5: Focused regression tests, the full root test suite, package verification, documentation build, and repository secret scan complete successfully.
- AC-6: Agent instructions prohibit proactive changelog, release-note, version, and task-external metadata edits unless the user or a pre-existing requirement authorizes them; user-visible behavior alone is insufficient.

## Non-goals

- Redesigning the Host capability registry, Evidence Bundle cache, report model, or CLI command registry.
- Changing scoring, finding generation, report prose, or host support claims.
- Adding remote Preview authentication or changing release automation in this change.
- Refactoring unrelated large modules or introducing a generic `scripts/core/` utility layer.
- Publishing an npm release.

## Plan and Tasks

1. Add RED coverage for a spaced symbolic installation path, then compare canonical real paths in the `cloc` direct-entrypoint check.
2. Add Checkup regressions for symbolic-link source parents and backup roots. Validate each backup directory component and canonical containment before writing without changing normal patch semantics.
3. Add Secret Scan CLI tests for unreadable/missing explicit paths, nested symbolic links, safety-sensitive read skips, and coverage status. Make incomplete scans fail closed with a stable distinct exit code while retaining current finding behavior.
4. Add parameterized privacy-safe text tests for GitLab and hierarchical URL userinfo credentials, then extend shared review-text sanitization to redact them.
5. Run focused tests after each RED→GREEN slice, then the complete verification gates.
6. Record the maintainer-requested change-scope rule in `AGENTS.md`, remove the proactive changelog entry, and perform traceability, security, and code-quality review before commit and merge.

Affected owners are expected to remain limited to:

- `scripts/cloc/`
- `scripts/coding-agent-practices/checkup/`
- `scripts/agent-guardrails/`
- `scripts/session-analysis/`
- their focused tests and this spec
- `AGENTS.md` for the explicit task-scope rule

## Test and Review Evidence

- AC-1: `node --test test/cloc.test.mjs test/better-harness-cli.test.mjs`, including the spaced symbolic installation path
- AC-2: focused Checkup tests in `test/harness-checkup.test.mjs` for source and backup symlink rejection
- AC-3: focused Secret Guard tests in `test/agent-guardrails-secret-scan.test.mjs`, including nested-link, safety-skip, exit-code, and JSON coverage assertions
- AC-4: focused privacy/session tests covering exact credential non-retention across hierarchical URL schemes
- AC-6: inspect the `AGENTS.md` diff and confirm `CHANGELOG.md` has no PR delta
- AC-5:
- `npm test`
- `npm run pack:verify`
- `node --test test/doc-link-graph.test.mjs`
- `npm ci && npm run build` from `docs/`
- repository secret scan over the final diff/worktree

Risk notes:

- Path containment changes must remain cross-platform and must not reject normal Windows/macOS/Linux in-root files.
- Secret Scan exit-code changes are intentional machine-contract behavior and require explicit regression coverage.
- Redaction must favor removing credential material over preserving diagnostic fidelity.
- No destructive external action is part of implementation; PR publication occurs only after verification and a sensitive-data scan.
27 changes: 21 additions & 6 deletions scripts/agent-guardrails/secret-scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export async function scanPaths(pathsToScan = ["."], options = {}) {
return {
tool: "secret-scan.mjs",
version: VERSION,
coverageStatus: coverageStatus(stats),
summary: summarize([], stats),
findings: [],
stats,
Expand All @@ -415,12 +416,18 @@ export async function scanPaths(pathsToScan = ["."], options = {}) {
return {
tool: "secret-scan.mjs",
version: VERSION,
coverageStatus: coverageStatus(stats),
summary: summarize(filtered, stats),
findings: filtered.map((finding) => publicFinding(finding, opts)),
stats,
};
}

function coverageStatus(stats) {
if (stats.errors.length === 0) return "complete";
return stats.scannedFiles > 0 ? "partial" : "failed";
}

function resolveHookPlatform({ platform, host } = {}) {
if (platform && host && String(platform).trim().toLowerCase() !== String(host).trim().toLowerCase()) {
throw new Error(`host (${host}) and platform (${platform}) must match when both are provided`);
Expand Down Expand Up @@ -577,6 +584,11 @@ function isProtectedCredentialPath(filePath) {
return PROTECTED_FILE_SEGMENTS.some((segment) => normalized === segment || normalized.endsWith(`/${segment}`) || normalized.includes(`/${segment}/`));
}

function recordCoverageGap(stats, file, reason) {
stats.skippedFiles += 1;
stats.errors.push(`${file}: ${reason}`);
}

async function collectFiles(absPath, out, opts, stats) {
let stat;
try {
Expand All @@ -587,14 +599,14 @@ async function collectFiles(absPath, out, opts, stats) {
}

if (stat.isSymbolicLink()) {
stats.skippedFiles += 1;
recordCoverageGap(stats, absPath, "symbolic-link scan targets are not inspected");
return;
}
if (opts.containmentRoot) {
try {
const canonical = await fs.realpath(absPath);
if (!isWithinRoot(opts.containmentRoot, canonical)) {
stats.skippedFiles += 1;
recordCoverageGap(stats, absPath, "scan target resolves outside the containment root");
return;
}
} catch (error) {
Expand Down Expand Up @@ -632,6 +644,8 @@ async function collectFiles(absPath, out, opts, stats) {
continue;
}
if (!shouldSkipFile(child, childStat, opts, stats)) out.push(child);
} else if (entry.isSymbolicLink()) {
await collectFiles(child, out, opts, stats);
}
}
}
Expand Down Expand Up @@ -663,18 +677,18 @@ async function readScannableFile(file, opts, stats) {
try {
beforeOpen = await fs.lstat(file);
if (beforeOpen.isSymbolicLink() || !beforeOpen.isFile()) {
stats.skippedFiles += 1;
recordCoverageGap(stats, file, "scan target changed type before it could be read safely");
return null;
}
canonical = await fs.realpath(file);
if (opts.containmentRoot && !isWithinRoot(opts.containmentRoot, canonical)) {
stats.skippedFiles += 1;
recordCoverageGap(stats, file, "scan target resolves outside the containment root");
return null;
}
handle = await openReadOnlyNoFollow(file);
const opened = await handle.stat();
if (!opened.isFile() || !sameFileIdentity(beforeOpen, opened)) {
stats.skippedFiles += 1;
recordCoverageGap(stats, file, "scan target changed while it was being opened");
return null;
}
const buffer = await handle.readFile();
Expand Down Expand Up @@ -924,7 +938,8 @@ export async function main(argv = process.argv.slice(2)) {
if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
else process.stdout.write(printHuman(report, options));

return report.findings.some((finding) => shouldFailFinding(finding, options.failOn)) ? 2 : 0;
if (report.findings.some((finding) => shouldFailFinding(finding, options.failOn))) return 2;
return report.coverageStatus === "complete" ? 0 : 3;
}

function validateFailOn(failOn) {
Expand Down
16 changes: 15 additions & 1 deletion scripts/cloc/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
#!/usr/bin/env node

import { realpathSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { analyzeCloc } from "./analyze.mjs";

function parseArgs(argv = process.argv.slice(2)) {
Expand Down Expand Up @@ -64,7 +68,17 @@ export async function main(argv = process.argv.slice(2)) {
printTextReport(report);
}

if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/"))) {
function isDirectEntrypoint(entrypoint) {
if (!entrypoint) return false;
const currentFile = fileURLToPath(import.meta.url);
try {
return realpathSync(entrypoint) === realpathSync(currentFile);
} catch {
return path.resolve(entrypoint) === path.resolve(currentFile);
}
}

if (isDirectEntrypoint(process.argv[1])) {
main().catch((error) => {
process.stderr.write(`cloc failed: ${error.stack ?? error.message}\n`);
process.exitCode = 1;
Expand Down
78 changes: 75 additions & 3 deletions scripts/coding-agent-practices/checkup/apply.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import {
chmod,
copyFile,
lstat,
mkdir,
readFile,
realpath,
rename,
rm,
stat,
Expand Down Expand Up @@ -73,7 +76,68 @@ export function resolveSourceRef(sourceRef, options = {}) {
if (!ALLOWED_SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
throw new Error("source patch target must be a supported instruction or hook source file");
}
return { filePath, workspace, key: sourceKey(sourceRef) };
return { filePath, root, workspace, key: sourceKey(sourceRef) };
}

async function assertSafeSourceTarget({ filePath, root }) {
const canonicalRoot = await realpath(root);
const relativeTarget = path.relative(path.resolve(root), filePath);
let current = path.resolve(root);
for (const part of relativeTarget.split(path.sep)) {
current = path.join(current, part);
const metadata = await lstat(current);
if (metadata.isSymbolicLink()) {
throw new Error("source patch target path must not contain a symbolic link");
}
}
const canonicalTarget = await realpath(filePath);
const canonicalRelative = path.relative(canonicalRoot, canonicalTarget);
if (canonicalRelative === "" || canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) {
throw new Error("source patch target escapes its declared base");
}
}

function isWithinCanonicalRoot(root, target, { allowRoot = false } = {}) {
const relative = path.relative(root, target);
if (relative === "") return allowRoot;
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}

async function ensureSafeBackupDirectory({ directoryPath, root }) {
const resolvedRoot = path.resolve(root);
const resolvedDirectory = path.resolve(directoryPath);
const relativeDirectory = path.relative(resolvedRoot, resolvedDirectory);
if (!isWithinCanonicalRoot(resolvedRoot, resolvedDirectory)) {
throw new Error("backup path escapes the workspace");
}

const canonicalRoot = await realpath(resolvedRoot);
let current = resolvedRoot;
for (const part of relativeDirectory.split(path.sep)) {
current = path.join(current, part);
let metadata;
try {
metadata = await lstat(current);
} catch (error) {
if (error.code !== "ENOENT") throw error;
try {
await mkdir(current);
} catch (mkdirError) {
if (mkdirError.code !== "EEXIST") throw mkdirError;
}
metadata = await lstat(current);
}
if (metadata.isSymbolicLink()) {
throw new Error("backup path must not contain a symbolic link");
}
if (!metadata.isDirectory()) {
throw new Error("backup path components must be directories");
}
const canonicalCurrent = await realpath(current);
if (!isWithinCanonicalRoot(canonicalRoot, canonicalCurrent, { allowRoot: true })) {
throw new Error("backup path escapes the workspace");
}
}
}

function replaceLines(content, patch) {
Expand Down Expand Up @@ -160,6 +224,7 @@ async function atomicReplace(filePath, content) {
export async function applySourcePatch(action, options = {}) {
const sourceRef = action.mutation?.sourceRef;
const resolved = resolveSourceRef(sourceRef, options);
await assertSafeSourceTarget(resolved);
const content = await readFile(resolved.filePath, "utf8");
const expectedFingerprint = action.sourceFingerprints?.[resolved.key];
if (!expectedFingerprint || sha256(content) !== expectedFingerprint) {
Expand All @@ -172,8 +237,15 @@ export async function applySourcePatch(action, options = {}) {
const backupRoot = path.join(resolved.workspace, ".better-harness-checkup-backups");
const relativeBackup = backupName(sourceRef, options.now);
const backupPath = path.join(backupRoot, relativeBackup);
await mkdir(path.dirname(backupPath), { recursive: true });
await copyFile(resolved.filePath, backupPath);
await ensureSafeBackupDirectory({ directoryPath: path.dirname(backupPath), root: resolved.workspace });
await assertSafeSourceTarget(resolved);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Validate the backup write path as well as the source path

assertSafeSourceTarget(resolved) protects the source file, but backupPath is still created and written without canonical containment or component-level symlink checks. If .better-harness-checkup-backups is a symlink to a directory outside the workspace, this patch succeeds and copyFile() writes the backup outside the authorized root.

Please create/validate the backup tree without following symbolic-link components, verify its real path remains under the canonical workspace, and add a regression for a symlinked backup root.

await ensureSafeBackupDirectory({ directoryPath: path.dirname(backupPath), root: resolved.workspace });
await copyFile(resolved.filePath, backupPath, fsConstants.COPYFILE_EXCL);
const canonicalWorkspace = await realpath(resolved.workspace);
const canonicalBackup = await realpath(backupPath);
if (!isWithinCanonicalRoot(canonicalWorkspace, canonicalBackup)) {
throw new Error("backup path escapes the workspace");
}
await atomicReplace(resolved.filePath, replacement);
const verifiedContent = await readFile(resolved.filePath, "utf8");
let parser = "readable";
Expand Down
2 changes: 2 additions & 0 deletions scripts/session-analysis/privacy-safe-text.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export function sanitizePrivateReviewText(value, { limit = 800 } = {}) {
.replace(/\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+\/-]{8,}\b/giu, "Bearer <redacted>")
.replace(/\b(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)\s*[:=]\s*[^\s,;]+/giu, "$1=<redacted>")
.replace(/\b(?:sk|ghp|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}\b/giu, "<secret>")
.replace(/\bglpat-[A-Za-z0-9_-]{20,}\b/giu, "<secret>")
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#@]+@/giu, "$1<redacted>@")
.replace(/\bAKIA[0-9A-Z]{12,}\b/gu, "<secret>")
.replace(/(^|[\s("'`@])(?:\.{1,2}[\\/])?(?:[\p{L}\p{N}._-]+[\\/])+[\p{L}\p{N}._-]+/gmu, "$1<path>")
.replace(/(^|[^\p{L}\p{N}_])\/(?:Users|home|var|private|tmp|opt)\/[^\s"'`<>]+/gmu, "$1<path>")
Expand Down
Loading