Skip to content

Commit 14c75e2

Browse files
authored
fix(miner-governor): require global live opt-in (#5231)
resolveMinerActionMode required EITHER the operator's global env config OR the target repo's own .gittensory-miner.yml opt-in to reach live mode. That meant a repo's own manifest alone could enable real miner writes without the operator ever opting their instance into live execution. Changes the resolver from OR to AND -- the repo field is now a repo-side allowance, not a standalone authorization. Fixes two test fixtures (test/unit/miner-attempt-runner.test.ts's allowingGovernorContext, test/unit/miner-governor-chokepoint-persisted.test.ts's baseInput) whose "everything allows" defaults set only the global opt-in, relying on the old OR semantics to reach live -- both now set the repo opt-in too, matching the new AND requirement.
1 parent 2e9fab7 commit 14c75e2

8 files changed

Lines changed: 52 additions & 23 deletions

File tree

packages/gittensory-engine/src/governor/action-mode.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type MinerActionMode = "paused" | "dry_run" | "live";
2323
export const MINER_LIVE_MODE_OPT_IN = "live";
2424

2525
/** Env var an operator sets (to exactly {@link MINER_LIVE_MODE_OPT_IN}) to opt their own miner instance into
26-
* live write execution, independent of any per-repo `.gittensory-miner.yml` opt-in. */
26+
* live write execution. Repo-side opt-in alone is never enough to execute writes. */
2727
export const MINER_LIVE_MODE_ENV_VAR = "GITTENSORY_MINER_LIVE_MODE";
2828

2929
/** True only when `value` is EXACTLY the {@link MINER_LIVE_MODE_OPT_IN} string -- no truthy coercion, no case
@@ -40,22 +40,24 @@ export function isGlobalMinerLiveModeOptIn(env: Record<string, string | undefine
4040
/**
4141
* Resolve the miner's overall action mode. Precedence (safest wins, mirroring `resolveAgentActionMode`):
4242
* 1. Kill-switch active (either scope, #2341) -> `"paused"` -- always wins, regardless of any live-mode opt-in.
43-
* 2. An explicit live-mode opt-in from EITHER the operator's global env config OR the target repo's own
44-
* `.gittensory-miner.yml` (`MinerGoalSpec.execution.liveModeOptIn`) -> `"live"`.
45-
* 3. Otherwise -> `"dry_run"`. No config anywhere, or a malformed/partial config that fails to normalize to the
46-
* exact opt-in literal, both fall through to this branch -- absence or ambiguity always means dry-run.
43+
* 2. BOTH the operator's global env config AND the target repo's own `.gittensory-miner.yml`
44+
* (`MinerGoalSpec.execution.liveModeOptIn`) explicitly opt in -> `"live"`. The repo field is a repo-side
45+
* allowance, not an operator-authored authorization to execute writes under the miner's credentials.
46+
* 3. Otherwise -> `"dry_run"`. No config anywhere, either side omitted, or a malformed/partial config that fails
47+
* to normalize to the exact opt-in literal, all fall through to this branch -- absence or ambiguity always
48+
* means dry-run.
4749
*
4850
* A target repo that wants to guarantee it never receives live automated writes -- even from an operator whose
49-
* own miner instance is globally live -- sets its OWN kill-switch (`killSwitch.paused: true`, #2341), which
50-
* takes precedence over any live-mode opt-in per step 1 above; this module does not duplicate that mechanism.
51+
* own miner instance is globally live -- can omit its repo opt-in or set its OWN kill-switch (`killSwitch.paused:
52+
* true`, #2341), which takes precedence over any live-mode opt-in per step 1 above.
5153
*/
5254
export function resolveMinerActionMode(input: {
5355
killSwitchScope: MinerKillSwitchScope;
5456
repoLiveModeOptIn?: unknown;
5557
globalLiveModeOptIn: boolean;
5658
}): MinerActionMode {
5759
if (isMinerKillSwitchActive(input.killSwitchScope)) return "paused";
58-
if (input.globalLiveModeOptIn || isExplicitMinerLiveModeOptIn(input.repoLiveModeOptIn)) return "live";
60+
if (input.globalLiveModeOptIn && isExplicitMinerLiveModeOptIn(input.repoLiveModeOptIn)) return "live";
5961
return "dry_run";
6062
}
6163

packages/gittensory-engine/test/action-mode.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,23 @@ test("resolveMinerActionMode: malformed/partial opt-in values fail closed to dry
5252
}
5353
});
5454

55-
test("resolveMinerActionMode: the exact repo-side opt-in flips to live", () => {
55+
test("resolveMinerActionMode: the exact repo-side opt-in alone stays dry_run without operator opt-in", () => {
5656
assert.equal(
5757
resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: "live", globalLiveModeOptIn: false }),
58-
"live",
58+
"dry_run",
5959
);
6060
});
6161

62-
test("resolveMinerActionMode: the global operator opt-in alone also flips to live", () => {
62+
test("resolveMinerActionMode: the global operator opt-in alone also stays dry_run without repo opt-in", () => {
6363
assert.equal(
6464
resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: undefined, globalLiveModeOptIn: true }),
65+
"dry_run",
66+
);
67+
});
68+
69+
test("resolveMinerActionMode: both repo and operator opt-ins are required for live", () => {
70+
assert.equal(
71+
resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: "live", globalLiveModeOptIn: true }),
6572
"live",
6673
);
6774
});

packages/gittensory-engine/test/chokepoint.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function baseInput(overrides: Partial<GovernorChokepointInput> = {}): GovernorCh
1212
killSwitchGlobal: false,
1313
killSwitchRepoPaused: false,
1414
liveModeGlobalOptIn: true,
15-
liveModeRepoOptIn: undefined,
15+
liveModeRepoOptIn: "live",
1616
rateLimitBuckets: { global: {}, perRepo: {} },
1717
rateLimitBackoffAttempts: {},
1818
capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 },
@@ -195,8 +195,15 @@ test("fail-closed: a self-plagiarism calculator error denies rather than silentl
195195
assert.match(decision.reason, /self_plagiarism_calculator_error/);
196196
});
197197

198-
test("the repo-side live opt-in alone (no global env opt-in) is sufficient to reach the resource stages", () => {
198+
test("the repo-side live opt-in alone (no global env opt-in) stays dry_run before resource stages", () => {
199199
const decision = evaluateGovernorChokepoint(baseInput({ liveModeGlobalOptIn: false, liveModeRepoOptIn: "live" }));
200+
assert.equal(decision.mode, "dry_run");
201+
assert.equal(decision.stage, "dry_run");
202+
assert.equal(decision.allowed, false);
203+
});
204+
205+
test("both repo-side and global live opt-ins are required to reach the resource stages", () => {
206+
const decision = evaluateGovernorChokepoint(baseInput({ liveModeGlobalOptIn: true, liveModeRepoOptIn: "live" }));
200207
assert.equal(decision.mode, "live");
201208
assert.equal(decision.allowed, true);
202209
});

packages/gittensory-miner/lib/governor-action-mode.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Governor dry-run-by-default gate (#2342). Resolves the miner's overall action mode (paused > dry_run > live,
22
// "safest wins") and records dry-run SHADOW actions to the append-only governor ledger. A freshly-configured
3-
// miner defaults to dry_run -- live execution requires an explicit, hard-to-fat-finger opt-in.
3+
// miner defaults to dry_run -- live execution requires explicit, hard-to-fat-finger operator and repo opt-ins.
44

55
import {
66
buildMinerDryRunGovernorLedgerEvent,
@@ -13,7 +13,7 @@ import { appendGovernorEvent } from "./governor-ledger.js";
1313
/**
1414
* Resolve the miner's overall action mode from the kill-switch scope (see `checkMinerKillSwitch` in
1515
* `./governor-kill-switch.js`), the repo's own `.gittensory-miner.yml` opt-in, and the operator's global env
16-
* opt-in.
16+
* opt-in. Both sides must opt in before real writes execute; repo config alone only preserves dry-run.
1717
*
1818
* @param {object} input
1919
* @param {import("@jsonbored/gittensory-engine").MinerKillSwitchScope} input.killSwitchScope

test/unit/miner-attempt-runner.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ function allowingGovernorContext(overrides: Record<string, unknown> = {}) {
103103
killSwitchGlobal: false,
104104
killSwitchRepoPaused: false,
105105
liveModeGlobalOptIn: true,
106-
liveModeRepoOptIn: undefined,
106+
liveModeRepoOptIn: "live",
107107
rateLimitBuckets: { global: {}, perRepo: {} },
108108
rateLimitBackoffAttempts: {},
109109
capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 },

test/unit/miner-governor-action-mode.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,26 @@ describe("resolveMinerActionModeGate (#2342)", () => {
2626
});
2727
});
2828

29-
it("the repo's exact opt-in flips to live", () => {
29+
it("the repo's exact opt-in alone stays dry_run without operator opt-in", () => {
3030
expect(resolveMinerActionModeGate({ killSwitchScope: "none", repoLiveModeOptIn: "live", env: {} })).toEqual({
31-
mode: "live",
32-
executes: true,
31+
mode: "dry_run",
32+
executes: false,
3333
});
3434
});
3535

36-
it("the operator's global env opt-in alone also flips to live", () => {
36+
it("the operator's global env opt-in alone also stays dry_run without repo opt-in", () => {
3737
expect(
3838
resolveMinerActionModeGate({ killSwitchScope: "none", env: { GITTENSORY_MINER_LIVE_MODE: "live" } }),
39+
).toEqual({ mode: "dry_run", executes: false });
40+
});
41+
42+
it("requires both repo and operator opt-ins for live execution", () => {
43+
expect(
44+
resolveMinerActionModeGate({
45+
killSwitchScope: "none",
46+
repoLiveModeOptIn: "live",
47+
env: { GITTENSORY_MINER_LIVE_MODE: "live" },
48+
}),
3949
).toEqual({ mode: "live", executes: true });
4050
});
4151

@@ -55,7 +65,10 @@ describe("resolveMinerActionModeGate (#2342)", () => {
5565
const original = process.env.GITTENSORY_MINER_LIVE_MODE;
5666
try {
5767
process.env.GITTENSORY_MINER_LIVE_MODE = "live";
58-
expect(resolveMinerActionModeGate({ killSwitchScope: "none" })).toEqual({ mode: "live", executes: true });
68+
expect(resolveMinerActionModeGate({ killSwitchScope: "none", repoLiveModeOptIn: "live" })).toEqual({
69+
mode: "live",
70+
executes: true,
71+
});
5972
} finally {
6073
if (original === undefined) delete process.env.GITTENSORY_MINER_LIVE_MODE;
6174
else process.env.GITTENSORY_MINER_LIVE_MODE = original;

test/unit/miner-governor-chokepoint-persisted.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function baseInput(overrides: Record<string, unknown> = {}) {
4444
killSwitchGlobal: false,
4545
killSwitchRepoPaused: false,
4646
liveModeGlobalOptIn: true,
47-
liveModeRepoOptIn: undefined,
47+
liveModeRepoOptIn: "live",
4848
capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 },
4949
convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
5050
...overrides,

test/unit/miner-governor-chokepoint.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function baseInput(overrides: Record<string, unknown> = {}) {
2222
killSwitchGlobal: false,
2323
killSwitchRepoPaused: false,
2424
liveModeGlobalOptIn: true,
25-
liveModeRepoOptIn: undefined,
25+
liveModeRepoOptIn: "live",
2626
rateLimitBuckets: { global: {}, perRepo: {} },
2727
rateLimitBackoffAttempts: {},
2828
capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 },

0 commit comments

Comments
 (0)