Skip to content

Commit 305ee58

Browse files
authored
fix(harness): fail closed at review and privacy boundaries (#19)
Close issues #11 through #18 with fail-closed workspace, Git, filesystem, and review boundaries. Include the maintainer follow-up fixes for per-record Qoder scoping, explicit user-global analysis, Git probe errors, empty cwd values, and bounded cloc reads.
1 parent 942c062 commit 305ee58

18 files changed

Lines changed: 1494 additions & 48 deletions
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Fail-Closed Review and Privacy Boundaries
2+
3+
## Traceability
4+
5+
- Spec ID: review-boundary-hardening-11-18
6+
- Story: #11, #12, #13, #14, #15, #16, #17, #18
7+
- Status: Implemented
8+
9+
## Intent
10+
11+
Make Better Harness honor its declared privacy controls and fail closed when
12+
review, filesystem, or report-integrity boundaries cannot be verified. The
13+
change fixes eight independently reproduced High findings without expanding
14+
the product surface or changing successful-path report semantics.
15+
16+
## Acceptance Scenarios
17+
18+
- AC-01 (#11): Secret Guard normalizes supported snake_case and camelCase tool
19+
payloads, scans secret-bearing Write/Edit/apply_patch content, blocks
20+
synthetic secrets without echoing them, and preserves existing Bash/path
21+
decisions.
22+
- AC-02 (#12): Workspace-scoped Qoder analysis excludes home-only sessions that
23+
have no verified relationship to the requested workspace across sessions,
24+
events/show, facets, insights, file-reads, and usage-summary. A matching
25+
record cannot authorize foreign-cwd records from the same home session, and
26+
cwd-less records require a workspace-linked session source instead of being
27+
assigned to the requested workspace. An explicit global-capability pass can
28+
retain home-only sessions only as `user-global` evidence.
29+
- AC-03 (#13): `report --no-sessions` executes no session probe and always uses
30+
the static `software-fluency` route even when local sessions exist.
31+
- AC-04 (#14): Blast-radius collection distinguishes an invalid/unavailable
32+
base ref from a real empty diff and returns an explicit fail-closed error.
33+
- AC-05 (#15): review-trigger argument, Git worktree probes, and runtime
34+
failures exit non-zero while successful findings retain their documented
35+
non-blocking result.
36+
- AC-06 (#16): `report --cwd` rejects empty, missing, and non-directory targets
37+
before starting evidence collectors; valid-directory fallback behavior
38+
remains available.
39+
- AC-07 (#17): findings repair never lowers `Critical` or arbitrary unknown
40+
severity to `Medium`; unsupported values fail closed or use an explicit
41+
conservative mapping.
42+
- AC-08 (#18): Git-backed cloc never follows a tracked path to a target outside
43+
the repository, reads a non-regular file, or reads a regular file beyond the
44+
configured size boundary; skipped results remain bounded and do not reveal
45+
the external target.
46+
- AC-09: Focused tests, the full Node test suite, package verification, syntax
47+
checks, and review-readiness checks pass on Linux-compatible local tooling;
48+
cross-platform path behavior remains covered by portable fixtures.
49+
50+
## Non-goals
51+
52+
- Do not redesign report scoring, add a new severity level to the public
53+
findings schema, or alter valid High/Medium/Low findings.
54+
- Do not add authentication or network sharing to Canvas preview.
55+
- Do not change the Medium large-untracked-file finding or architecture
56+
watchlist items from the originating review.
57+
- Do not add dependencies.
58+
- Do not refactor unrelated session, report, or Git-analysis behavior.
59+
60+
## Plan and Tasks
61+
62+
1. Add failing regression tests for each issue before changing implementation.
63+
2. Normalize Secret Guard tool events once, then scan only the content fields
64+
owned by matched write tools.
65+
3. Apply workspace ownership at both Qoder home-session discovery and per-event
66+
hydration, keeping explicit `user-global` behavior separate.
67+
4. Make quickstart privacy and empty/missing cwd validation explicit at its CLI
68+
boundary.
69+
5. Replace Git diff ambiguity with a structured failure from blast-radius
70+
collection.
71+
6. Separate review-trigger argument, Git-probe, and execution failures from
72+
successful non-blocking findings.
73+
7. Make severity repair conservative and make cloc file reads type-, size-,
74+
and containment-aware.
75+
8. Run focused tests per module, then the complete repository checks.
76+
9. Perform independent code-review and architecture lanes, address all
77+
Critical/High findings and any architectural Block, then run Review
78+
Readiness before commit and PR.
79+
80+
Decision rationale:
81+
82+
- Fail closed only when the tool cannot establish the requested boundary;
83+
preserve supported fallback behavior for valid inputs with partial evidence.
84+
- Prefer small checks at existing owner boundaries over new shared abstraction
85+
layers.
86+
- Use synthetic credentials and temporary repositories only; tests must not
87+
retain private prompts, paths, or secrets.
88+
89+
## Test and Review Evidence
90+
91+
- AC-01:
92+
`node --test test/agent-guardrails-secret-scan.test.mjs`
93+
- AC-02:
94+
`node --test test/session-analysis.test.mjs test/session-usage-summary.test.mjs`
95+
including mixed-cwd, cwd-less, and explicit user-global home-session cases
96+
- AC-03 and AC-06:
97+
`node --test test/harness-quickstart.test.mjs test/better-harness-cli.test.mjs`
98+
- AC-04:
99+
`node --test test/blast-radius.test.mjs`
100+
- AC-05:
101+
`node --test test/review-trigger.test.mjs`, including a non-Git directory
102+
- AC-07:
103+
`node --test test/harness-findings-repair.test.mjs test/harness-report-render-cli.test.mjs`
104+
- AC-08:
105+
`node --test test/cloc.test.mjs`, including an oversized regular file
106+
- AC-09:
107+
`npm test`
108+
`npm run pack:verify`
109+
`node --test test/doc-link-graph.test.mjs`
110+
111+
Review evidence:
112+
113+
- Review follow-up (2026-07-29): mixed-cwd and cwd-less home-session hydration,
114+
explicit user-global analysis, failed Git worktree probes, empty `--cwd=`,
115+
and bounded cloc reads are covered by regressions and resolved before merge.
116+
- `npm run check` passed with the full Node suite and package verification.
117+
- The review follow-up focused run passed 80 tests across Qoder session
118+
analysis, session usage summary, review-trigger, quickstart, and cloc;
119+
syntax and diff checks also passed.
120+
- Code-reviewer recommendation: `APPROVE` after both reported High findings
121+
were fixed and re-reviewed.
122+
- Architect status: non-blocking `WATCH`; the previous fail-open `BLOCK` was
123+
resolved. The remaining watch is limited to the concurrent swap-to-symlink
124+
race on platforms where `O_NOFOLLOW` is unavailable.
125+
- The final diff must contain no generated runtime state, credentials, or
126+
unrelated source changes.
127+
128+
Risk notes:
129+
130+
- Qoder home-session filtering can reduce previously over-broad results; tests
131+
must prove verified workspace sessions remain visible.
132+
- Hook exit-code changes can expose previously hidden configuration errors;
133+
success-path non-blocking tests guard the intended contract.
134+
- Symlink policy differs by platform; fixtures must avoid requiring privileged
135+
symlink creation where the platform does not support it.

hooks/git-scripts/blast-radius/analysis.mjs

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,55 @@
11
import path from "node:path";
22

33
import { CONFIG_PATH, isIgnored, loadConfig } from "./config.mjs";
4-
import { collectGitChanges } from "./git.mjs";
4+
import { collectGitChanges, isGitFailureError } from "./git.mjs";
55
import { buildCodeGraph, mapChangedSymbols } from "./graph.mjs";
66
import { isTestFile } from "./parser.mjs";
77
import { matchesAnyPattern, normalizeRelativePath, unique } from "./utils.mjs";
8+
9+
function emptyMetrics() {
10+
return {
11+
changedFiles: 0,
12+
changedLines: 0,
13+
changedSymbols: 0,
14+
impactedSymbols: 0,
15+
impactedFiles: 0,
16+
testGaps: 0,
17+
securityRemovals: 0,
18+
parsedFiles: 0,
19+
};
20+
}
21+
22+
function gitFailureReport(error) {
23+
const message = error.code === "GIT_BASE_REF_UNAVAILABLE"
24+
? `Blast radius could not verify git base ref "${error.ref}"; failing closed because changed files cannot be distinguished from a real empty diff.`
25+
: `Blast radius git collection failed (${error.code}); failing closed because changed files cannot be distinguished from a real empty diff.`;
26+
27+
return {
28+
status: "error",
29+
shouldReview: true,
30+
score: 100,
31+
severity: "critical",
32+
reasons: [message],
33+
metrics: emptyMetrics(),
34+
changedFiles: [],
35+
changedSymbols: [],
36+
affectedSymbols: [],
37+
affectedFiles: [],
38+
coreHits: [],
39+
testGaps: [],
40+
securityRemovals: [],
41+
error: {
42+
type: "git",
43+
code: error.code,
44+
message: error.message,
45+
ref: error.ref,
46+
command: error.command,
47+
status: error.status,
48+
stderr: error.stderr,
49+
},
50+
configPath: CONFIG_PATH,
51+
};
52+
}
853
function enrichCallers(changedSymbols, graph, config) {
954
return changedSymbols.map((symbol) => {
1055
const callerIds = graph.reverseEdges.get(symbol.id) ?? [];
@@ -264,7 +309,15 @@ function computeScore(metrics, changedSymbols, impact, coreHits, testGaps, secur
264309
export async function analyzeRepository(repoRoot, options = {}) {
265310
const root = path.resolve(repoRoot || process.cwd());
266311
const config = options.config ?? (await loadConfig(root, options.configPath));
267-
const changes = options.changes ?? (await collectGitChanges(root, config));
312+
let changes;
313+
try {
314+
changes = options.changes ?? (await collectGitChanges(root, config));
315+
} catch (error) {
316+
if (isGitFailureError(error)) {
317+
return gitFailureReport(error);
318+
}
319+
throw error;
320+
}
268321
const changedFiles = changes.files
269322
.map((file) => ({
270323
...file,
@@ -279,16 +332,7 @@ export async function analyzeRepository(repoRoot, options = {}) {
279332
);
280333

281334
if (changedFiles.length === 0) {
282-
const metrics = {
283-
changedFiles: 0,
284-
changedLines: 0,
285-
changedSymbols: 0,
286-
impactedSymbols: 0,
287-
impactedFiles: 0,
288-
testGaps: 0,
289-
securityRemovals: 0,
290-
parsedFiles: 0,
291-
};
335+
const metrics = emptyMetrics();
292336
return {
293337
status: "ok",
294338
shouldReview: false,

hooks/git-scripts/blast-radius/git.mjs

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@ import path from "node:path";
66
import { SECURITY_REMOVAL_RE } from "./config.mjs";
77
import { countLines } from "./utils.mjs";
88

9+
export class GitFailureError extends Error {
10+
constructor(message, details) {
11+
super(message);
12+
this.name = "GitFailureError";
13+
this.code = details.code ?? "GIT_COMMAND_FAILED";
14+
this.command = details.command;
15+
this.status = details.status;
16+
this.signal = details.signal;
17+
this.stdout = details.stdout;
18+
this.stderr = details.stderr;
19+
this.ref = details.ref;
20+
}
21+
}
22+
23+
export function isGitFailureError(error) {
24+
return error instanceof GitFailureError;
25+
}
26+
27+
function trimOutput(output) {
28+
return String(output ?? "").trim().slice(0, 600);
29+
}
30+
931
function runGit(repoRoot, args, options = {}) {
1032
const result = spawnSync("git", args, {
1133
cwd: repoRoot,
@@ -18,12 +40,44 @@ function runGit(repoRoot, args, options = {}) {
1840
if (options.allowFailure) {
1941
return "";
2042
}
21-
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`);
43+
throw new GitFailureError(
44+
options.message ?? `git ${args.join(" ")} failed`,
45+
{
46+
code: options.code,
47+
command: ["git", ...args],
48+
status: result.status,
49+
signal: result.signal,
50+
stdout: trimOutput(result.stdout),
51+
stderr: trimOutput(result.stderr),
52+
ref: options.ref,
53+
},
54+
);
2255
}
2356

2457
return result.stdout;
2558
}
2659

60+
function verifyBaseRef(repoRoot, baseRef) {
61+
const ref = String(baseRef ?? "");
62+
if (!ref.trim()) {
63+
throw new GitFailureError("Blast radius git base ref is empty or unavailable", {
64+
code: "GIT_BASE_REF_UNAVAILABLE",
65+
command: ["git", "rev-parse", "--verify", "<empty>^{commit}"],
66+
status: 1,
67+
signal: null,
68+
stdout: "",
69+
stderr: "",
70+
ref,
71+
});
72+
}
73+
74+
return runGit(repoRoot, ["rev-parse", "--verify", `${ref}^{commit}`], {
75+
code: "GIT_BASE_REF_UNAVAILABLE",
76+
message: `Blast radius git base ref is unavailable: ${ref}`,
77+
ref,
78+
}).trim();
79+
}
80+
2781
export function parseUnifiedDiff(diffText) {
2882
return parseUnifiedDiffDetails(diffText).ranges;
2983
}
@@ -118,22 +172,24 @@ function parseNumstat(numstatText) {
118172

119173
export async function collectGitChanges(repoRoot, config) {
120174
const baseRef = process.env.BETTER_HARNESS_BLAST_RADIUS_BASE ?? config.baseRef ?? "HEAD";
175+
const baseCommit = verifyBaseRef(repoRoot, baseRef);
176+
121177
const diffText = runGit(
122178
repoRoot,
123-
["diff", "--unified=0", "--no-ext-diff", "--find-renames", baseRef, "--"],
124-
{ allowFailure: true },
179+
["diff", "--unified=0", "--no-ext-diff", "--find-renames", baseCommit, "--"],
180+
{ code: "GIT_DIFF_FAILED", ref: baseRef },
125181
);
126182
const numstat = runGit(
127183
repoRoot,
128-
["diff", "--numstat", "--no-ext-diff", "--find-renames", baseRef, "--"],
129-
{ allowFailure: true },
184+
["diff", "--numstat", "--no-ext-diff", "--find-renames", baseCommit, "--"],
185+
{ code: "GIT_DIFF_FAILED", ref: baseRef },
130186
);
131187

132188
const files = parseNumstat(numstat);
133189
const diffDetails = parseUnifiedDiffDetails(diffText);
134190
const ranges = diffDetails.ranges;
135191
const untracked = runGit(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"], {
136-
allowFailure: true,
192+
code: "GIT_UNTRACKED_FAILED",
137193
})
138194
.split("\0")
139195
.filter(Boolean);

0 commit comments

Comments
 (0)