fix(daemon,cli): strict threshold_percent validation (slice 27 BLOCKING-FIX)

threshold_percent contract is integer in [1, 100]. parseInt's permissive
coercion accepted invalid input at write paths (rig config set, /api/config
POST, env override, direct ConfigStore.set):

- 0 (would fire /compact every poll tick — catastrophic if opt-in net
  ever weakens)
- 101, -1 (out of documented range)
- "80abc" (partial parse → 80)
- "80.5" (truncated → 80)

Two layers added (per banked feedback_static_gates_mirror_runtime_validators
+ feedback_permission_layer_is_foot_gun_dont_patch_reactively):

1. WRITE-path validators in both ConfigStore (cli) + SettingsStore
   (daemon) — a per-key constraint map runs after coerceValue in set();
   strict regex `^-?\d+$` + Number.isInteger + range check. The
   /api/config POST handler already maps thrown errors to 400 via the
   existing try/catch in routes/config.ts; no new route surface.

2. ENFORCER defense-in-depth — ClaudeCompactionEnforcer treats
   out-of-contract policy as `invalid_policy` (no send) so a hand-edited
   ~/.openrig/config.json with bad values cannot trigger /compact.
   Mirrors the write-path constraint without crashing the daemon on
   bad config.

Tests: accept/reject matrices at each layer (CLI 16 cases, daemon
settings-store 16 cases, /api/config POST 9 reject + 1 accept,
enforcer 6 invalid_policy cases). 48 new tests; full daemon + cli
suites green.

Per banked feedback_pre_dispatch_grep_load_bearing_claims: grep
confirmed threshold_percent is the only numeric key in the
policies.claude_compaction.* namespace — enabled is bool;
message_inline / message_file_path are strings.

Source verdict: qitem-20260512100940-49cd26f4 (redo-guard-2 BLOCKING)
Driver-fix dispatch: qitem-20260512101023-f75668ac
Original slice ship: fe50ce7 (slice 27 claude auto-compaction policy)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 03:15:09 -07:00
1 parent fe50ce7759
commit efb0aa14ed
7 files changed
+241 -1

No files matched your search

+39
View File
@@ -405,6 +405,44 @@ function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string
return raw;
}
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
// in `set()`. The generic coerce uses parseInt which accepts partial
// parses (e.g., "80abc" → 80) and truncates fractions ("80.5" → 80); for
// keys with documented range/integer contracts this is unsafe. Per
// banked feedback_static_gates_mirror_runtime_validators, the runtime
// validator is the source of truth and must reject what the contract
// forbids.
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README §"What the operator gets" — operator can lower to
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
// would fire /compact on every poll tick (catastrophic without
// default-off as backstop).
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 100) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
);
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (check) check(raw, coerced);
}
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
@@ -557,6 +595,7 @@ export class ConfigStore {
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|| DEFAULT_WORKSPACE_ROOT;
const coerced = coerceValue(key, value, workspaceRoot);
validateKeyConstraints(key, value, coerced);
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
@@ -385,6 +385,42 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
});
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
// policies.claude_compaction.threshold_percent. The contract is
// integer in [1, 100]. parseInt's permissive coercion would otherwise
// accept "80abc" → 80, "80.5" → 80, and even 0 / 101 / -1 which would
// break the trigger's safety contract (0 = compact every tick).
describe("policies.claude_compaction.threshold_percent strict validation matrix", () => {
const accept = ["1", "2", "50", "80", "99", "100"];
const reject: Array<{ raw: string; reason: RegExp }> = [
{ raw: "0", reason: /must be in \[1, 100\]/ },
{ raw: "101", reason: /must be in \[1, 100\]/ },
{ raw: "-1", reason: /must be in \[1, 100\]/ },
{ raw: "80abc", reason: /expected an integer/ },
{ raw: "abc80", reason: /expected a number|expected an integer/ },
{ raw: "80.5", reason: /expected an integer/ },
{ raw: "", reason: /expected a number|expected an integer/ },
{ raw: " ", reason: /expected a number|expected an integer/ },
{ raw: "NaN", reason: /expected a number|expected an integer/ },
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
];
for (const value of accept) {
it(`accepts ${JSON.stringify(value)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
expect(store.get("policies.claude_compaction.threshold_percent")).toBe(Number(value));
});
}
for (const { raw, reason } of reject) {
it(`rejects ${JSON.stringify(raw)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
});
}
});
});
describe("parseNamedPairs", () => {
@@ -45,7 +45,8 @@ export type EnforcerSkipReason =
| "disabled"
| "below_threshold"
| "dedup_window"
| "send_failed";
| "send_failed"
| "invalid_policy";
export class ClaudeCompactionEnforcer {
private readonly settingsStore: SettingsStore;
@@ -80,6 +81,22 @@ export class ClaudeCompactionEnforcer {
if (!policy.enabled) {
return { triggered: false, reason: "disabled" };
}
// Defense in depth: the CLI + daemon set() paths reject invalid
// threshold values, but a hand-edited ~/.openrig/config.json could
// still inject 0, 101, NaN, or a non-integer. The enforcer treats
// out-of-contract policy as disabled (safer-failure direction) so
// compaction lifecycle remains operator-controlled even on bad
// config. Mirrors the per-key constraint in
// user-settings/settings-store.ts KEY_CONSTRAINTS.
if (
typeof policy.thresholdPercent !== "number"
|| !Number.isFinite(policy.thresholdPercent)
|| !Number.isInteger(policy.thresholdPercent)
|| policy.thresholdPercent < 1
|| policy.thresholdPercent > 100
) {
return { triggered: false, reason: "invalid_policy" };
}
if (input.usedPercentage < policy.thresholdPercent) {
return { triggered: false, reason: "below_threshold" };
}
@@ -322,6 +322,41 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
return raw;
}
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
// daemon's HTTP write surface (/api/config POST) rejects the same input
// the CLI rejects.
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
// reads at every poll tick; runtime validator rejects what the
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 100) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
);
}
},
};
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (check) check(raw, coerced);
}
/**
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
* consumes this snapshot per-poll; the PreCompact hook reads the same
@@ -437,6 +472,7 @@ export class SettingsStore {
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
const coerced = coerceValue(key, value, wr);
validateKeyConstraints(key, value, coerced);
setNestedValue(fc, KEY_TO_PATH[key], coerced);
mkdirSync(path.dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
@@ -209,4 +209,45 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send.mock.calls[0]![0]).toBe("a@rig");
expect(send.mock.calls[1]![0]).toBe("b@rig");
});
// Slice 27 BLOCKING-FIX — defense-in-depth at the enforcer.
//
// The CLI + daemon set() paths reject invalid threshold input, but a
// hand-edited ~/.openrig/config.json could still inject 0, 101, NaN,
// or a non-integer (the file read path passes the value through
// without re-validation by design). The enforcer is the last
// safety net before send: it MUST treat an out-of-contract
// thresholdPercent as disabled (returns `invalid_policy`, no send).
describe("BLOCKING-FIX: defense-in-depth on hand-edited bad policy values", () => {
const cases: Array<{ name: string; thresholdPercent: number }> = [
{ name: "0 (would trigger on every tick)", thresholdPercent: 0 },
{ name: "101 (above range)", thresholdPercent: 101 },
{ name: "-1 (below range)", thresholdPercent: -1 },
{ name: "80.5 (non-integer)", thresholdPercent: 80.5 },
{ name: "NaN", thresholdPercent: Number.NaN },
{ name: "Infinity", thresholdPercent: Number.POSITIVE_INFINITY },
];
for (const c of cases) {
it(`treats threshold=${c.name} as invalid_policy; never sends`, async () => {
const settings = makeSettingsStore({
enabled: true,
thresholdPercent: c.thresholdPercent,
messageInline: "",
messageFilePath: "",
});
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99, // would trigger if policy were valid
});
expect(outcome).toEqual({ triggered: false, reason: "invalid_policy" });
expect(send).not.toHaveBeenCalled();
});
}
});
});
@@ -143,6 +143,39 @@ describe("config routes (User Settings v0)", () => {
expect(res.status).toBe(400);
});
// Slice 27 BLOCKING-FIX — /api/config POST must reject invalid
// threshold_percent input. The route catches the error thrown by
// SettingsStore.set and maps it to 400; the integration test asserts
// the contract end-to-end so a future drift gets caught at CI.
describe("POST /api/config/policies.claude_compaction.threshold_percent strict validation", () => {
const rejectCases = ["0", "101", "-1", "80abc", "80.5", "", " ", "NaN", "Infinity"];
for (const raw of rejectCases) {
it(`returns 400 for ${JSON.stringify(raw)}`, async () => {
const app = buildApp();
const res = await app.request("/api/config/policies.claude_compaction.threshold_percent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: raw }),
});
expect(res.status).toBe(400);
const body = await res.json() as { error?: string };
expect(body.error).toMatch(/integer|number|in \[1, 100\]/);
});
}
it("accepts valid integer in range and persists to disk", async () => {
const app = buildApp();
const res = await app.request("/api/config/policies.claude_compaction.threshold_percent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "60" }),
});
expect(res.status).toBe(200);
expect(JSON.parse(readFileSync(configPath, "utf-8")).policies.claudeCompaction.thresholdPercent).toBe(60);
});
});
it("DELETE /api/config/:key resets to default", async () => {
store.set("workspace.slices_root", "/x");
const app = buildApp();
@@ -306,6 +306,44 @@ describe("SettingsStore (User Settings v0)", () => {
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
});
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
// threshold_percent. Per banked feedback_static_gates_mirror_runtime_validators,
// the runtime validator is the source of truth; this test invokes the
// same `set()` path the daemon's /api/config POST handler uses so a
// future drift gets caught at CI.
describe("HG-1 strict threshold validation matrix", () => {
const cases = {
accept: ["1", "2", "50", "80", "99", "100"],
reject: [
{ raw: "0", reason: /must be in \[1, 100\]/ },
{ raw: "101", reason: /must be in \[1, 100\]/ },
{ raw: "-1", reason: /must be in \[1, 100\]/ },
{ raw: "80abc", reason: /expected an integer/ },
{ raw: "abc80", reason: /expected a number|expected an integer/ },
{ raw: "80.5", reason: /expected an integer/ },
{ raw: "", reason: /expected a number|expected an integer/ },
{ raw: " ", reason: /expected a number|expected an integer/ },
{ raw: "NaN", reason: /expected a number|expected an integer/ },
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
],
};
for (const value of cases.accept) {
it(`accepts ${JSON.stringify(value)}`, () => {
const store = new SettingsStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(Number(value));
});
}
for (const { raw, reason } of cases.reject) {
it(`rejects ${JSON.stringify(raw)}`, () => {
const store = new SettingsStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
});
}
});
void existsSync;
});