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
+82 -7
View File
@@ -110,6 +110,10 @@ function gsMain(fn) {
try {
fn();
} catch (e) {
if (e && e.gstackUnreadableSettings === true) {
process.stderr.write("gstack-settings-hook: " + e.message + " -- refusing to mutate\n");
process.exit(3);
}
process.stderr.write("gstack-settings-hook: internal error (" + (e && e.message) + ") -- refusing to mutate\n");
process.exit(4);
}
@@ -161,6 +165,49 @@ function gsWinPath(p) {
}
return p;
}
function gsResolveSettingsPath(input) {
var fs = require("fs");
var path = require("path");
var absolute = path.resolve(gsWinPath(input));
var resolved;
try { resolved = fs.realpathSync(absolute); }
catch (e) {
if (e && (e.code === "EACCES" || e.code === "EPERM")) {
throw Object.assign(new Error("cannot read " + absolute + " (" + e.code + ")"), { gstackUnreadableSettings: true });
}
if (!e || e.code !== "ENOENT") throw e;
try {
if (fs.lstatSync(absolute).isSymbolicLink()) throw new Error("settings link has no target");
} catch (missing) {
if (!missing || missing.code !== "ENOENT") throw missing;
}
resolved = path.join(gsResolveSettingsPath(path.dirname(absolute)), path.basename(absolute));
}
return process.platform === "win32" ? resolved.replace(/\\/g, "/") : resolved;
}
function gsAssertSettingsTarget(settingsPath) {
if (gsResolveSettingsPath(process.env.GSTACK_SETTINGS_INPUT) !== settingsPath) {
throw new Error("settings target changed while waiting or writing");
}
}
function gsSettingsIdentity(settingsPath) {
var fs = require("fs");
try {
var stat = fs.lstatSync(settingsPath, { bigint: true });
if (!stat.isFile()) throw new Error("settings target is not a regular file");
return [stat.dev, stat.ino, stat.size, stat.mode, stat.mtimeNs, stat.ctimeNs].join(":");
} catch (e) {
if (e && e.code === "ENOENT") return null;
throw e;
}
}
var gsLoadedIdentity;
function gsAssertSettingsUnchanged(settingsPath) {
gsAssertSettingsTarget(settingsPath);
if (gsSettingsIdentity(settingsPath) !== gsLoadedIdentity) {
throw new Error("settings changed during mutation");
}
}
function gsIsAlive(cmd) {
var fs = require("fs");
var p = gsWinPath(gsStripWrap(cmd));
@@ -214,6 +261,8 @@ function gsRotateBackups(settingsPath, keep) {
}
function gsLoadSettings(path) {
var fs = require("fs");
gsAssertSettingsTarget(path);
gsLoadedIdentity = gsSettingsIdentity(path);
var raw = null;
try { raw = fs.readFileSync(path, "utf8"); }
catch (e) {
@@ -231,6 +280,7 @@ function gsWriteIfChanged(path, beforeText, settings, existed) {
var fs = require("fs");
var afterText = JSON.stringify(settings, null, 2);
if (afterText === beforeText) return false;
gsAssertSettingsUnchanged(path);
// Preserve the live file mode across the tmp+rename (settings.json can
// carry API keys in its env block -- a user-tightened 0600 must never be
// silently broadened to the default 0644). Fresh files start 0600.
@@ -242,13 +292,24 @@ function gsWriteIfChanged(path, beforeText, settings, existed) {
gsRotateBackups(path, 10);
}
var tmp = process.env.GSTACK_TMP_PATH;
fs.writeFileSync(tmp, afterText + "\n");
try { fs.chmodSync(tmp, mode); } catch (e) {}
fs.renameSync(tmp, path);
fs.writeFileSync(tmp, afterText + "\n", { mode: mode, flag: "wx" });
try {
fs.chmodSync(tmp, mode);
gsAssertSettingsUnchanged(path);
fs.renameSync(tmp, path);
} finally {
try { fs.unlinkSync(tmp); } catch (e) { if (e.code !== "ENOENT") throw e; }
}
return true;
}
'
GSTACK_SETTINGS_INPUT="$SETTINGS_FILE"
export GSTACK_SETTINGS_INPUT
SETTINGS_FILE=$(bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
process.stdout.write(gsResolveSettingsPath(process.env.GSTACK_SETTINGS_INPUT));
});') || exit $?
# ─── Mutation lock ────────────────────────────────────────────────────
# Accepted tradeoffs (adversarial-reviewed): (1) the lock serializes gstack
# writers only -- Claude Code rewrites settings.json without honoring it, so a
@@ -805,6 +866,7 @@ case "$ACTION" in
;;
rollback)
_acquire_lock || exit 1
if [ ! -f "$SETTINGS_FILE.bak-latest" ]; then
echo "rollback: no backup pointer at $SETTINGS_FILE.bak-latest" >&2
exit 1
@@ -831,10 +893,23 @@ case "$ACTION" in
echo "rollback: pointer references missing backup $LATEST" >&2
exit 1
fi
_acquire_lock || exit 1
_RB_TMP="$SETTINGS_FILE.tmp.$$.$RANDOM"
cp "$LATEST" "$_RB_TMP"
mv "$_RB_TMP" "$SETTINGS_FILE"
_mutation_env
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_RESTORE_PATH="$LATEST" \
bun -e "$_HOOK_JS_PRELUDE"'gsMain(function () {
var fs = require("fs");
var target = process.env.GSTACK_SETTINGS_PATH;
var backup = process.env.GSTACK_RESTORE_PATH;
var tmp = process.env.GSTACK_TMP_PATH;
gsAssertSettingsTarget(target);
gsLoadedIdentity = gsSettingsIdentity(target);
try {
fs.copyFileSync(backup, tmp, fs.constants.COPYFILE_EXCL);
gsAssertSettingsUnchanged(target);
fs.renameSync(tmp, target);
} finally {
try { fs.unlinkSync(tmp); } catch (e) { if (e.code !== "ENOENT") throw e; }
}
});'
echo "OK: restored $SETTINGS_FILE from $LATEST"
;;