Skip to content

Commit e3f215d

Browse files
authored
feat(engine): add operator-declared network-egress allowlist to AmsPolicySpec (#8204)
Extends .loopover-ams.yml with a networkAllowlist field (ecosystems + extraHosts) so an operator can declare additional network-egress allowances for their own AMS attempts. Deliberately operator-local only, matching this file's existing scope -- deriving allowlist contents from the TARGET repo's own manifest would let a malicious repo smuggle an attacker-controlled host into its own attempt's allowlist, against the trust boundary this config surface already enforces for every other field. Config surface only. No OS-level enforcement exists yet for AMS sandboxed execution -- that mechanism is still an open decision (documented on #7857), deliberately deferred separately from this trust-boundary-safe declaration surface so it doesn't need to be reopened once enforcement is designed. Part of #7857.
1 parent ada9725 commit e3f215d

4 files changed

Lines changed: 259 additions & 1 deletion

File tree

packages/loopover-engine/src/ams-policy-spec.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,42 @@ export type AmsCapLimits = {
4242
elapsedMs: number;
4343
};
4444

45+
/** Curated ecosystem identifiers an operator may declare in {@link AmsNetworkAllowlist.ecosystems} -- the
46+
* language/package-manager registries #7648 ratified as a safe default category. A closed set (not free
47+
* text) so a typo degrades to a warning + drop, not a silently-ignored no-op. */
48+
export const AMS_NETWORK_ALLOWLIST_ECOSYSTEMS = ["npm", "pypi", "crates", "go", "rubygems", "packagist", "maven", "nuget"] as const;
49+
export type AmsNetworkAllowlistEcosystem = (typeof AMS_NETWORK_ALLOWLIST_ECOSYSTEMS)[number];
50+
51+
const MAX_NETWORK_ALLOWLIST_ECOSYSTEMS = AMS_NETWORK_ALLOWLIST_ECOSYSTEMS.length;
52+
const MAX_NETWORK_ALLOWLIST_EXTRA_HOSTS = 50;
53+
// RFC 1123 hostname shape (labels of letters/digits/hyphens, dot-separated, no leading/trailing hyphen per
54+
// label) -- deliberately conservative since a future enforcement implementation (#7857's still-open mechanism
55+
// half) will feed this straight into firewall/proxy rules; garbage here would be that implementation's problem
56+
// to sanitize a second time.
57+
const HOSTNAME_RE = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
58+
59+
/** Operator-declared network-egress allowlist additions (#7857, config-surface half of #7648's ratified
60+
* design) for AMS sandboxed execution. Deliberately operator-local ONLY, mirroring this whole file's own
61+
* scope (see the module header) -- never fetched from a target repo. #7648 ratified "the repo's declared
62+
* language-ecosystem registries" as a default-allowlist category, but deriving that from a TARGET repo's own
63+
* manifest is unsafe: a malicious repo could fabricate a manifest entry to smuggle an attacker-controlled
64+
* host into its own attempt's allowlist -- exactly the kind of repo-loosens-its-own-constraints hole this
65+
* file's whole design already guards against. The operator declares which ecosystems and any extra hosts
66+
* their own repos legitimately need instead.
67+
*
68+
* INERT today: no OS-level network-egress enforcement exists yet for AMS sandboxed execution (#7857's
69+
* mechanism half is still open, deliberately deferred separately from this config surface). This type is
70+
* what a future enforcement implementation will read; landing it now settles the trust-boundary question
71+
* ahead of that work instead of leaving it to be reopened once enforcement is being built. */
72+
export type AmsNetworkAllowlist = {
73+
/** Ecosystem registries to allow, beyond the two categories #7648 ratified as always-on (OS package
74+
* registries, the repo's own git remote) -- those aren't declared here since they apply unconditionally. */
75+
ecosystems: AmsNetworkAllowlistEcosystem[];
76+
/** Additional specific hostnames to allow beyond the curated ecosystem categories, e.g. a project's own
77+
* third-party API (#7648's "requesting broader access" case). */
78+
extraHosts: string[];
79+
};
80+
4581
/** Per-operator AMS execution policy parsed from `.loopover-ams.yml`. See {@link DEFAULT_AMS_POLICY_SPEC}. */
4682
export type AmsPolicySpec = {
4783
/** Whether a real attempt may actually submit. Default: "observe" (deny-by-default). */
@@ -65,6 +101,10 @@ export type AmsPolicySpec = {
65101
* hands off), so defaulting to "observe" would silently change behavior for every operator who leaves the
66102
* field unset. Inert until the consultation issue reads it. */
67103
selfLoopAutonomy: AutonomyLevel;
104+
/** Operator-declared network-egress allowlist additions (#7857). Default: `{ ecosystems: [], extraHosts: [] }`
105+
* -- no additions beyond the always-on OS-registry/git-remote defaults. INERT until #7857's OS-level
106+
* enforcement mechanism is built; see {@link AmsNetworkAllowlist}'s own doc comment. */
107+
networkAllowlist: AmsNetworkAllowlist;
68108
};
69109

70110
/** The tolerant parser result for `.loopover-ams.yml`. Mirrors `ParsedMinerGoalSpec`'s present/warnings shape. */
@@ -86,6 +126,7 @@ export const DEFAULT_AMS_POLICY_SPEC: Readonly<AmsPolicySpec> = Object.freeze({
86126
maxIterations: 3,
87127
maxTurnsPerIteration: 6,
88128
selfLoopAutonomy: "auto",
129+
networkAllowlist: Object.freeze({ ecosystems: [], extraHosts: [] }),
89130
});
90131

91132
const MAX_AMS_POLICY_SPEC_BYTES = 8_192;
@@ -99,6 +140,10 @@ function cloneDefaultAmsPolicySpec(): AmsPolicySpec {
99140
maxIterations: DEFAULT_AMS_POLICY_SPEC.maxIterations,
100141
maxTurnsPerIteration: DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration,
101142
selfLoopAutonomy: DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy,
143+
networkAllowlist: {
144+
ecosystems: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.ecosystems],
145+
extraHosts: [...DEFAULT_AMS_POLICY_SPEC.networkAllowlist.extraHosts],
146+
},
102147
};
103148
}
104149

@@ -185,6 +230,67 @@ function normalizeConvergenceThresholds(
185230
};
186231
}
187232

233+
/** Validates each entry independently and DROPS invalid ones rather than falling back to the whole list --
234+
* unlike this file's single-value fields (one bad value = the whole field reverts to default), a list field
235+
* reverting entirely on one typo would silently discard every other correctly-typed entry alongside it. */
236+
function normalizeEcosystemList(value: unknown, fallback: AmsNetworkAllowlistEcosystem[], warnings: string[]): AmsNetworkAllowlistEcosystem[] {
237+
// A fresh copy, not `fallback` by reference: unlike this file's number-valued fields, an array is mutable,
238+
// so passing through the DEFAULT_AMS_POLICY_SPEC singleton's own array here would let a caller who mutates
239+
// their OWN resolved spec's list (e.g. `.push`) silently corrupt every other caller's shared defaults too.
240+
if (value === undefined || value === null) return [...fallback];
241+
if (!Array.isArray(value)) {
242+
warnings.push('AmsPolicySpec field "networkAllowlist.ecosystems" must be an array; falling back to defaults.');
243+
return [...fallback];
244+
}
245+
const known = new Set<string>(AMS_NETWORK_ALLOWLIST_ECOSYSTEMS);
246+
const result: AmsNetworkAllowlistEcosystem[] = [];
247+
for (const entry of value.slice(0, MAX_NETWORK_ALLOWLIST_ECOSYSTEMS)) {
248+
if (typeof entry === "string" && known.has(entry) && !result.includes(entry as AmsNetworkAllowlistEcosystem)) {
249+
result.push(entry as AmsNetworkAllowlistEcosystem);
250+
continue;
251+
}
252+
warnings.push(
253+
`AmsPolicySpec field "networkAllowlist.ecosystems" entry ${JSON.stringify(entry)} must be one of ${AMS_NETWORK_ALLOWLIST_ECOSYSTEMS.join(", ")}; dropping it.`,
254+
);
255+
}
256+
return result;
257+
}
258+
259+
/** Same drop-invalid-entries approach as {@link normalizeEcosystemList}. Hostname shape is validated (not just
260+
* "is this a string") because this feeds a future firewall/proxy enforcement implementation directly -- see
261+
* {@link AmsNetworkAllowlist}'s own doc comment. */
262+
function normalizeExtraHosts(value: unknown, fallback: string[], warnings: string[]): string[] {
263+
// Fresh copies throughout, same reasoning as normalizeEcosystemList's own comment above.
264+
if (value === undefined || value === null) return [...fallback];
265+
if (!Array.isArray(value)) {
266+
warnings.push('AmsPolicySpec field "networkAllowlist.extraHosts" must be an array; falling back to defaults.');
267+
return [...fallback];
268+
}
269+
const result: string[] = [];
270+
for (const entry of value.slice(0, MAX_NETWORK_ALLOWLIST_EXTRA_HOSTS)) {
271+
if (typeof entry === "string" && HOSTNAME_RE.test(entry) && !result.includes(entry)) {
272+
result.push(entry);
273+
continue;
274+
}
275+
warnings.push(`AmsPolicySpec field "networkAllowlist.extraHosts" entry ${JSON.stringify(entry)} is not a valid hostname; dropping it.`);
276+
}
277+
return result;
278+
}
279+
280+
function normalizeNetworkAllowlist(value: unknown, fallback: AmsNetworkAllowlist, warnings: string[]): AmsNetworkAllowlist {
281+
// Fresh array copies in the fallback object too, same reasoning as normalizeEcosystemList's own comment.
282+
if (value === undefined || value === null) return { ecosystems: [...fallback.ecosystems], extraHosts: [...fallback.extraHosts] };
283+
if (typeof value !== "object" || Array.isArray(value)) {
284+
warnings.push('AmsPolicySpec field "networkAllowlist" must be a mapping; falling back to defaults.');
285+
return { ecosystems: [...fallback.ecosystems], extraHosts: [...fallback.extraHosts] };
286+
}
287+
const record = value as Record<string, unknown>;
288+
return {
289+
ecosystems: normalizeEcosystemList(record.ecosystems, fallback.ecosystems, warnings),
290+
extraHosts: normalizeExtraHosts(record.extraHosts, fallback.extraHosts, warnings),
291+
};
292+
}
293+
188294
function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean {
189295
return (
190296
spec.submissionMode !== DEFAULT_AMS_POLICY_SPEC.submissionMode ||
@@ -196,7 +302,12 @@ function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean {
196302
spec.convergenceThresholds.maxReenqueues !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues ||
197303
spec.maxIterations !== DEFAULT_AMS_POLICY_SPEC.maxIterations ||
198304
spec.maxTurnsPerIteration !== DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration ||
199-
spec.selfLoopAutonomy !== DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy
305+
spec.selfLoopAutonomy !== DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy ||
306+
// Default is always { ecosystems: [], extraHosts: [] } (see DEFAULT_AMS_POLICY_SPEC) -- any entry at all
307+
// means the operator configured something, so length alone is the right "differs from default" check;
308+
// no need to compare contents.
309+
spec.networkAllowlist.ecosystems.length > 0 ||
310+
spec.networkAllowlist.extraHosts.length > 0
200311
);
201312
}
202313

@@ -244,6 +355,7 @@ export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec {
244355
DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy,
245356
warnings,
246357
),
358+
networkAllowlist: normalizeNetworkAllowlist(record.networkAllowlist, DEFAULT_AMS_POLICY_SPEC.networkAllowlist, warnings),
247359
};
248360
if (!hasConfiguredPolicyFields(spec)) {
249361
warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults.");

packages/loopover-engine/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,10 @@ export {
552552
parseAmsPolicySpec,
553553
parseAmsPolicySpecContent,
554554
AMS_POLICY_SPEC_FILENAMES,
555+
AMS_NETWORK_ALLOWLIST_ECOSYSTEMS,
555556
type AmsCapLimits,
557+
type AmsNetworkAllowlist,
558+
type AmsNetworkAllowlistEcosystem,
556559
type AmsPolicySpec,
557560
type AmsSlopThreshold,
558561
type AmsSubmissionMode,

packages/loopover-engine/test/ams-policy-spec-parser.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ test("parseAmsPolicySpec: valid raw config normalizes every field and keeps non-
4141
maxIterations: 5,
4242
maxTurnsPerIteration: 10,
4343
selfLoopAutonomy: "observe",
44+
networkAllowlist: { ecosystems: ["npm"], extraHosts: ["api.example.com"] },
4445
});
4546

4647
assert.equal(parsed.present, true);
@@ -52,10 +53,19 @@ test("parseAmsPolicySpec: valid raw config normalizes every field and keeps non-
5253
maxIterations: 5,
5354
maxTurnsPerIteration: 10,
5455
selfLoopAutonomy: "observe",
56+
networkAllowlist: { ecosystems: ["npm"], extraHosts: ["api.example.com"] },
5557
});
5658
assert.deepEqual(parsed.warnings, []);
5759
});
5860

61+
test("parseAmsPolicySpec: networkAllowlist (#7857) defaults to no additions and normalizes a valid declaration", () => {
62+
assert.deepEqual(DEFAULT_AMS_POLICY_SPEC.networkAllowlist, { ecosystems: [], extraHosts: [] });
63+
const parsed = parseAmsPolicySpec({ networkAllowlist: { ecosystems: ["npm", "pypi"], extraHosts: ["api.example.com"] } });
64+
assert.equal(parsed.present, true);
65+
assert.deepEqual(parsed.spec.networkAllowlist, { ecosystems: ["npm", "pypi"], extraHosts: ["api.example.com"] });
66+
assert.deepEqual(parsed.warnings, []);
67+
});
68+
5969
test("parseAmsPolicySpec: selfLoopAutonomy defaults to auto when omitted (#6559)", () => {
6070
// "auto" is today's implicit behavior -- a clean self-review pass already hands off unconditionally -- so
6171
// this default is what keeps an unset field from silently changing an existing operator's loop.

0 commit comments

Comments
 (0)