From 3e030a7a49b9f7929c2c166f178f7b121dd48fd4 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:10:54 -0700 Subject: [PATCH] fix(miner): clamp repo AMS policy defaults --- packages/gittensory-miner/lib/ams-policy.js | 45 ++++++++++++++-- test/unit/miner-ams-policy.test.ts | 59 +++++++++++++++++++-- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/packages/gittensory-miner/lib/ams-policy.js b/packages/gittensory-miner/lib/ams-policy.js index 034065184..cec2dc20a 100644 --- a/packages/gittensory-miner/lib/ams-policy.js +++ b/packages/gittensory-miner/lib/ams-policy.js @@ -6,8 +6,9 @@ import { resolveLocalStoreDbPath } from "./local-store.js"; // (ams-policy-spec.ts, engine package) is the type/parser surface; this module is the actual fetch+resolve // caller, mirroring `.gittensory.yml`'s own established self-host precedent (src/selfhost/private-config.ts's // `makeLocalManifestReader`): the operator's own local file, when present, FULLY REPLACES whatever the -// target repo's own file says -- never a field-by-field merge. The repo's file is only ever consulted as a -// fallback default for an operator who hasn't set their own local policy. +// target repo's own file says -- never a field-by-field merge. The repo's file can only ever propose a +// more restrictive fallback for an operator who hasn't set their own local policy; repo-controlled content +// is clamped to the engine's safe deny-by-default policy before it becomes effective. // // This is deliberately NOT the same resolution shape as self-review-context.js/rejection-signal.js, which // only ever read from the target repo: AmsPolicySpec's fields are the OPERATOR's own execution-risk policy @@ -16,6 +17,42 @@ import { resolveLocalStoreDbPath } from "./local-store.js"; const AMS_POLICY_FILENAME = ".gittensory-ams.yml"; const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; +const SLOP_THRESHOLD_RANK = { clean: 0, low: 1, elevated: 2, high: 3 }; + +function clampNumberToDefault(value, fallback) { + return Math.min(value, fallback); +} + +function clampRepoAmsPolicySpec(spec, warnings) { + const clamped = { + submissionMode: spec.submissionMode === "observe" ? "observe" : DEFAULT_AMS_POLICY_SPEC.submissionMode, + slopThreshold: + SLOP_THRESHOLD_RANK[spec.slopThreshold] <= SLOP_THRESHOLD_RANK[DEFAULT_AMS_POLICY_SPEC.slopThreshold] + ? spec.slopThreshold + : DEFAULT_AMS_POLICY_SPEC.slopThreshold, + capLimits: { + budget: clampNumberToDefault(spec.capLimits.budget, DEFAULT_AMS_POLICY_SPEC.capLimits.budget), + turns: clampNumberToDefault(spec.capLimits.turns, DEFAULT_AMS_POLICY_SPEC.capLimits.turns), + elapsedMs: clampNumberToDefault(spec.capLimits.elapsedMs, DEFAULT_AMS_POLICY_SPEC.capLimits.elapsedMs), + }, + convergenceThresholds: { + maxConsecutiveFailures: clampNumberToDefault( + spec.convergenceThresholds.maxConsecutiveFailures, + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxConsecutiveFailures, + ), + maxReenqueues: clampNumberToDefault(spec.convergenceThresholds.maxReenqueues, DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues), + }, + maxIterations: clampNumberToDefault(spec.maxIterations, DEFAULT_AMS_POLICY_SPEC.maxIterations), + maxTurnsPerIteration: clampNumberToDefault(spec.maxTurnsPerIteration, DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration), + }; + + if (JSON.stringify(clamped) !== JSON.stringify(spec)) { + warnings.push("Repo-controlled AmsPolicySpec cannot loosen the miner's safe default execution policy; permissive fields were clamped."); + } + + return clamped; +} + /** Resolve the operator's local AMS policy file path: explicit env var > `GITTENSORY_MINER_CONFIG_DIR` > * `XDG_CONFIG_HOME`/`~/.config`, mirroring every other local-store path in this package. */ export function resolveAmsPolicyConfigPath(env = process.env) { @@ -73,7 +110,7 @@ async function fetchRepoAmsPolicyContent(target, resolved) { /** * Resolve the real, effective AMS execution policy for one attempt against `repoFullName`: the operator's * own local `.gittensory-ams.yml` when present (source: "local"), else the target repo's own proposed file - * when present (source: "repo"), else the engine's safe defaults (source: "default"). Never throws -- an + * clamped to safe defaults when present (source: "repo"), else the engine's safe defaults (source: "default"). Never throws -- an * unreadable/malformed file at either layer degrades to the next layer or the safe defaults, same discipline * as every other tolerant parser in this pipeline. * @@ -99,7 +136,7 @@ export async function resolveAmsPolicy(repoFullName, options = {}) { const repoContent = await fetchRepoAmsPolicyContent(target, resolved); if (repoContent !== null) { const parsed = parseAmsPolicySpecContent(repoContent); - return { spec: parsed.spec, source: "repo", warnings: parsed.warnings }; + return { spec: clampRepoAmsPolicySpec(parsed.spec, parsed.warnings), source: "repo", warnings: parsed.warnings }; } } diff --git a/test/unit/miner-ams-policy.test.ts b/test/unit/miner-ams-policy.test.ts index db7b71a8d..63b9e8f33 100644 --- a/test/unit/miner-ams-policy.test.ts +++ b/test/unit/miner-ams-policy.test.ts @@ -57,15 +57,63 @@ describe("resolveAmsPolicy (#5132)", () => { expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); }); - it("falls through to the repo's own .gittensory-ams.yml when no local file exists", async () => { + it("clamps repo fallback policy so repo-controlled content cannot loosen safe defaults", async () => { const root = tempRoot(); const fetchImpl = routedFetch({ - ".gittensory-ams.yml": () => textResponse("submissionMode: enforce\nslopThreshold: clean\n"), + ".gittensory-ams.yml": () => + textResponse( + [ + "submissionMode: enforce", + "slopThreshold: high", + "capLimits:", + " budget: 100", + " turns: 200", + " elapsedMs: 3600000", + "convergenceThresholds:", + " maxConsecutiveFailures: 20", + " maxReenqueues: 20", + "maxIterations: 30", + "maxTurnsPerIteration: 60", + ].join("\n"), + ), }); const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); expect(result.source).toBe("repo"); - expect(result.spec.submissionMode).toBe("enforce"); - expect(result.spec.slopThreshold).toBe("clean"); + expect(result.spec).toEqual(DEFAULT_AMS_POLICY_SPEC); + expect(result.warnings.join(" ")).toMatch(/cannot loosen/i); + }); + + it("allows repo fallback policy to make safe defaults more restrictive", async () => { + const root = tempRoot(); + const fetchImpl = routedFetch({ + ".gittensory-ams.yml": () => + textResponse( + [ + "submissionMode: observe", + "slopThreshold: clean", + "capLimits:", + " budget: 2", + " turns: 3", + " elapsedMs: 1000", + "convergenceThresholds:", + " maxConsecutiveFailures: 1", + " maxReenqueues: 0", + "maxIterations: 1", + "maxTurnsPerIteration: 2", + ].join("\n"), + ), + }); + const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); + expect(result.source).toBe("repo"); + expect(result.spec).toEqual({ + submissionMode: "observe", + slopThreshold: "clean", + capLimits: { budget: 2, turns: 3, elapsedMs: 1000 }, + convergenceThresholds: { maxConsecutiveFailures: 1, maxReenqueues: 0 }, + maxIterations: 1, + maxTurnsPerIteration: 2, + }); + expect(result.warnings).toEqual([]); }); it("REGRESSION: the operator's own local file fully REPLACES the repo's file, never merges", async () => { @@ -135,6 +183,7 @@ describe("resolveAmsPolicy (#5132)", () => { }); const result = await resolveAmsPolicy("acme/widgets", { fetchImpl, env: { GITTENSORY_MINER_CONFIG_DIR: root } }); expect(result.source).toBe("repo"); - expect(result.spec.submissionMode).toBe("enforce"); + expect(result.spec.submissionMode).toBe("observe"); + expect(result.warnings.join(" ")).toMatch(/cannot loosen/i); }); });