v1.88.1.0 fix: harden credential boundaries and owned state (#2942)

* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
This commit is contained in:
Garry Tan
2026-09-23 08:54:53 -04:00
committed by GitHub
parent 636175d349
commit b9706f3635
42 changed files with 2719 additions and 339 deletions
+31 -31
View File
@@ -121,6 +121,10 @@ export function normalizeWithMap(input: string): {
normalized: string;
map: number[];
} {
return normalizeOriginal(input);
}
function normalizeOriginal(input: string, spanEnds?: number[]): { normalized: string; map: number[] } {
const out: string[] = [];
const map: number[] = [];
let i = 0;
@@ -133,6 +137,7 @@ export function normalizeWithMap(input: string): {
for (const ch of rep) {
out.push(ch);
map.push(i);
spanEnds?.push(i + ent.length);
}
i += ent.length;
matchedEntity = true;
@@ -150,9 +155,10 @@ export function normalizeWithMap(input: string): {
ZERO_WIDTH.lastIndex = 0;
const norm = ch.normalize("NFKC");
for (const nch of norm) {
out.push(nch);
for (let j = 0; j < norm.length; j++) {
out.push(norm[j]);
map.push(i);
spanEnds?.push(i + 1);
}
i += 1;
}
@@ -327,6 +333,12 @@ function emailAllowed(
// ── The scan ──────────────────────────────────────────────────────────────────
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
return scanInternal(input, opts);
}
type OriginalSpan = { start: number; end: number };
function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, OriginalSpan>): ScanResult {
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
let starts: number[] | null = null; // line index, built on the first finding
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
@@ -363,7 +375,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
};
}
const { normalized, map } = normalizeWithMap(input);
const spanEnds: number[] | undefined = spans ? [] : undefined;
const { normalized, map } = normalizeOriginal(input, spanEnds);
const fenceRanges = toolFenceRanges(normalized);
const allow = new Set(opts.allowlist ?? []);
@@ -379,8 +392,7 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
if (m.index === re.lastIndex) re.lastIndex++;
const span = m[1] ?? m[0];
const spanStartInMatch = m[1] !== undefined ? m[0].indexOf(m[1]) : 0;
const normOffset = m.index + Math.max(0, spanStartInMatch);
const normOffset = m.indices?.[1]?.[0] ?? m.index;
// Per-span placeholder suppression.
if (isPlaceholderSpan(span)) continue;
@@ -421,7 +433,7 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
toolFenceDegraded = true;
}
findings.push({
const finding: Finding = {
id: pat.id,
tier: pat.tier,
severity,
@@ -433,7 +445,12 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
autoRedactable: !!pat.autoRedactable,
repoVisibility,
...(toolFenceDegraded ? { toolFenceDegraded } : {}),
});
};
findings.push(finding);
if (spans) {
const end = spanEnds?.[normOffset + span.length - 1];
if (end !== undefined && end > origOffset) spans.set(finding, { start: origOffset, end });
}
}
}
@@ -450,6 +467,7 @@ function withFlags(flags: string): string {
let f = flags;
if (!f.includes("g")) f += "g";
if (!f.includes("m")) f += "m";
if (!f.includes("d")) f += "d";
return f;
}
@@ -475,10 +493,11 @@ export function applyRedactions(
opts: ScanOptions = {},
): RedactResult {
const ids = new Set(findingIds);
const { findings } = scan(input, opts);
const spans = new Map<Finding, OriginalSpan>();
const { findings } = scanInternal(input, opts, spans);
const targets = findings
.filter((f) => ids.has(f.id) && f.autoRedactable)
.map((f) => ({ f, ...locateSpan(input, f) }))
.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }))
.filter((t) => t.start >= 0);
// Right-to-left so earlier offsets remain valid after splicing.
@@ -528,9 +547,10 @@ const MARKER_ONLY_PATTERN_IDS = new Set(["pem.private_key", "gcp.service_account
* structure-preserving path.)
*/
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
const { findings } = scan(input, opts);
const spans = new Map<Finding, OriginalSpan>();
const { findings } = scanInternal(input, opts, spans);
if (findings.some((f) => MARKER_ONLY_PATTERN_IDS.has(f.id))) return null;
const targets = findings.map((f) => ({ f, ...locateSpan(input, f) }));
const targets = findings.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }));
if (targets.some((t) => t.start < 0)) return null;
// Coalesce overlapping/touching ranges — splicing two intersecting spans
@@ -557,26 +577,6 @@ export function redactFindingSpans(input: string, opts: ScanOptions = {}): strin
return body;
}
function locateSpan(input: string, f: Finding): { start: number; end: number } {
// Re-derive the offset from line/col on the original text.
let offset = 0;
let line = 1;
while (line < f.line && offset < input.length) {
if (input[offset] === "\n") line++;
offset++;
}
offset += f.col - 1;
const pat = PATTERNS_BY_ID[f.id];
if (!pat) return { start: -1, end: -1 };
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
re.lastIndex = Math.max(0, offset - 2);
const m = re.exec(input);
if (!m) return { start: -1, end: -1 };
const span = m[1] ?? m[0];
const start = m.index + (m[1] !== undefined ? m[0].indexOf(m[1]) : 0);
return { start, end: start + span.length };
}
function inStructuralToken(body: string, start: number, end: number): boolean {
// Markdown link target: [text](...span...). The span may sit anywhere inside
// the parenthesized target (e.g. an email embedded in a URL). Walk backward