@@ -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 - z A - Z 0 - 9 ] (?: [ a - z A - Z 0 - 9 - ] { 0 , 61 } [ a - z A - Z 0 - 9 ] ) ? \. ) * [ a - z A - Z 0 - 9 ] (?: [ a - z A - Z 0 - 9 - ] { 0 , 61 } [ a - z A - Z 0 - 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}. */
4682export 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
91132const 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+
188294function 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." ) ;
0 commit comments