Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions packages/gittensory-miner/lib/ams-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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 };
}
}

Expand Down
59 changes: 54 additions & 5 deletions test/unit/miner-ams-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});