From 800b2c4b6115845f6bf15d90484b3e80e81a606f Mon Sep 17 00:00:00 2001 From: t Date: Wed, 1 Jul 2026 15:36:20 -0600 Subject: Load extensions via unified policy, entry/activate, rocks and paths Replace the split [tools]/[extensions] config sections and the two-stage load gate with a single model: - [extensions] is now one policy resolved across all layers. Rules from every layer are kept (not clobbered) and resolved by last-match-wins after sorting by (layer, glob specificity, deny-last), so a base layer can carve one name out of an otherwise-denied group and a higher layer's broad rule still wins. Default is allow; whitelist via deny=["**"]. - Registration is deferred: a source returns an entry {name, activate} (or a list, or the sugar tool table), is always eval'd side-effect-free, and only permitted names get activate()d. Identity is the declared name, not the filename. Collapses the old pre/post-load two-stage gate. - The loader runs two passes (eval -> shadow -> filter -> activate) with precedence project>user>base and, within a layer, rocks:`. -//! - `tools` / `extensions` allow- and deny-lists are captured verbatim -//! (as glob pattern lists). A pattern appearing in both allow and deny -//! for the same kind is a hard error. +//! - `[extensions]` allow/deny globs across all layers become one sorted +//! `Policy` rule list (see the merge-semantics note above). A pattern in +//! both allow and deny is no longer an error — resolution is deterministic +//! (deny wins the tie). //! //! Model *aliases* (the `` half of a model reference) are //! resolved separately against `models.toml` — this module only validates @@ -130,33 +137,126 @@ pub const ModelRef = struct { model: []const u8, }; -/// Tool/extension availability policy. Empty `allow` means "allow all" -/// (subject to `deny`). A name is permitted when it matches at least one -/// allow pattern (or allow is empty) AND matches no deny pattern. +pub const Verdict = enum { allow, deny }; + +/// One allow/deny glob rule, tagged with the config layer it came from and +/// its precomputed specificity. Rules are kept from every layer (never +/// clobbered) and resolved by "last matching rule wins" after sorting. +pub const Rule = struct { + pattern: []const u8, + verdict: Verdict, + layer: u16, + spec: glob.Specificity, +}; + +/// Extension availability policy: an ordered list of allow/deny glob rules +/// drawn from every config layer. The default is **allow** (an empty policy +/// permits everything). A name's verdict is the LAST rule that matches it, +/// where rules are sorted ascending by `(layer, specificity, allow-before-deny)`. +/// So a higher layer's rule beats a lower layer's; within a layer the more +/// specific pattern wins; at an exact tie `deny` wins. A pure whitelist is +/// `deny = ["**"]` plus specific `allow` carve-outs. pub const Policy = struct { - allow: [][]const u8, - deny: [][]const u8, + rules: []Rule = &.{}, pub fn deinit(self: Policy, alloc: Allocator) void { - for (self.allow) |p| alloc.free(p); - alloc.free(self.allow); - for (self.deny) |p| alloc.free(p); - alloc.free(self.deny); + for (self.rules) |r| alloc.free(r.pattern); + alloc.free(self.rules); } - /// True if `name` is permitted under this policy. + /// True if `name` is permitted. Scans the sorted rule list and keeps the + /// verdict of the last matching rule; defaults to allow when nothing + /// matches. pub fn permits(self: Policy, name: []const u8) bool { - for (self.deny) |pat| { - if (glob.match(pat, name)) return false; + var verdict: Verdict = .allow; + for (self.rules) |r| { + if (glob.match(r.pattern, name)) verdict = r.verdict; } - if (self.allow.len == 0) return true; - for (self.allow) |pat| { - if (glob.match(pat, name)) return true; + return verdict == .allow; + } +}; + +/// Ascending sort: `(layer, specificity, allow-before-deny)`. After sorting, +/// the last rule that matches a name is the winner. +fn ruleLessThan(_: void, a: Rule, b: Rule) bool { + if (a.layer != b.layer) return a.layer < b.layer; + switch (a.spec.order(b.spec)) { + .lt => return true, + .gt => return false, + .eq => {}, + } + // allow (0) sorts before deny (1) so deny wins an exact tie. + return @intFromEnum(a.verdict) < @intFromEnum(b.verdict); +} + +/// Append the `[extensions]` allow/deny rules from one layer's document to +/// `out`, tagged with `layer`. Duplicating patterns is harmless — resolution +/// is deterministic (deny wins ties). +fn collectRules( + allocator: Allocator, + root: *const toml.Value, + layer: u16, + out: *std.ArrayList(Rule), +) Error!void { + const tbl = root.get("extensions") orelse return; + if (tbl.* != .table) return; + inline for (.{ .{ "allow", Verdict.allow }, .{ "deny", Verdict.deny } }) |pair| { + const patterns = try stringArray(allocator, tbl, pair[0]); + defer allocator.free(patterns); // the slice; strings move into `out` + var i: usize = 0; + // On error, free only the patterns not yet moved into `out`; the + // moved ones (patterns[0..i]) are owned by `out` and freed by the caller. + errdefer for (patterns[i..]) |p| allocator.free(p); + while (i < patterns.len) : (i += 1) { + try out.append(allocator, .{ + .pattern = patterns[i], + .verdict = pair[1], + .layer = layer, + .spec = glob.specificity(patterns[i]), + }); } - return false; } +} + +/// Sort collected rules into resolution order and wrap in a `Policy`. +fn buildPolicy(allocator: Allocator, rules: *std.ArrayList(Rule)) Policy { + std.mem.sort(Rule, rules.items, {}, ruleLessThan); + return .{ .rules = rules.toOwnedSlice(allocator) catch &.{} }; +} + +/// A config string (a `paths` entry or a `rocks` spec) tagged with the layer +/// it came from, so the loader can assign it the right precedence. +pub const LayeredStr = struct { + value: []const u8, + layer: u16, }; +/// Append the string array `extensions.` from one layer to `out`, +/// tagged with `layer`. Used for `paths` and `rocks` (source lists that +/// accumulate across layers rather than clobbering). +fn collectStrings( + allocator: Allocator, + root: *const toml.Value, + key: []const u8, + layer: u16, + out: *std.ArrayList(LayeredStr), +) Error!void { + const tbl = root.get("extensions") orelse return; + if (tbl.* != .table) return; + const values = try stringArray(allocator, tbl, key); + defer allocator.free(values); + var i: usize = 0; + errdefer for (values[i..]) |v| allocator.free(v); + while (i < values.len) : (i += 1) { + try out.append(allocator, .{ .value = values[i], .layer = layer }); + } +} + +fn freeLayeredStrs(allocator: Allocator, list: []LayeredStr) void { + for (list) |s| allocator.free(s.value); + allocator.free(list); +} + /// Build a `panto.ProviderConfig` for a chosen `:` model /// reference, combining the resolved provider (transport/auth) with the /// model definition from `models.toml` (wire name + knobs). @@ -246,8 +346,15 @@ pub const Config = struct { /// `defaults.model` storage, owned. `default_model_ref` slices into it. default_model: ?[]const u8, default_model_ref: ?ModelRef, - tools: Policy, + /// Extension availability policy (the `[extensions]` section), resolved + /// across all config layers. Governs every lua-authored capability. extensions: Policy, + /// Extra filesystem dirs to scan for extensions (`extensions.paths`), + /// each tagged with the config layer that listed it. + ext_paths: []LayeredStr = &.{}, + /// luarocks dependency strings to load as extension sources + /// (`extensions.rocks`), each tagged with its config layer. + ext_rocks: []LayeredStr = &.{}, /// `[compaction]` settings. `compaction_keep_verbatim` is the kept- /// suffix token budget (null = use libpanto's default). `compaction_model` /// is an owned `:` reference string for the optional @@ -262,8 +369,9 @@ pub const Config = struct { self.allocator.free(self.auths); if (self.default_model) |m| self.allocator.free(m); if (self.compaction_model) |m| self.allocator.free(m); - self.tools.deinit(self.allocator); self.extensions.deinit(self.allocator); + freeLayeredStrs(self.allocator, self.ext_paths); + freeLayeredStrs(self.allocator, self.ext_rocks); // Frees every auth-config string, header array, resolved api key, // and provider `extra_headers` in one shot. self.auth_arena.deinit(); @@ -430,9 +538,51 @@ pub fn loadFromString( ) Error!Config { const doc = parseDoc(allocator, source) catch return error.InvalidConfigToml; defer doc.deinit(); - return resolve(allocator, environ_map, doc.root); + + var srcs = try SourceLists.collect(allocator, &.{doc.root}); + errdefer srcs.deinit(allocator); + const policy = buildPolicy(allocator, &srcs.rules); + + return resolve( + allocator, + environ_map, + doc.root, + policy, + try srcs.paths.toOwnedSlice(allocator), + try srcs.rocks.toOwnedSlice(allocator), + ); } +/// Accumulates the per-layer `[extensions]` rules and source lists during a +/// layered load. `collect` gathers from a slice of already-parsed layer +/// roots (lowest precedence first). +const SourceLists = struct { + rules: std.ArrayList(Rule) = .empty, + paths: std.ArrayList(LayeredStr) = .empty, + rocks: std.ArrayList(LayeredStr) = .empty, + + fn collect(allocator: Allocator, roots: []const *const toml.Value) Error!SourceLists { + var self: SourceLists = .{}; + errdefer self.deinit(allocator); + for (roots, 0..) |root, i| { + const layer: u16 = @intCast(i); + try collectRules(allocator, root, layer, &self.rules); + try collectStrings(allocator, root, "paths", layer, &self.paths); + try collectStrings(allocator, root, "rocks", layer, &self.rocks); + } + return self; + } + + fn deinit(self: *SourceLists, allocator: Allocator) void { + for (self.rules.items) |r| allocator.free(r.pattern); + self.rules.deinit(allocator); + for (self.paths.items) |s| allocator.free(s.value); + self.paths.deinit(allocator); + for (self.rocks.items) |s| allocator.free(s.value); + self.rocks.deinit(allocator); + } +}; + /// Load from explicit layer paths, lowest precedence first. Useful for /// tests. Missing files are skipped. pub fn loadFromPaths( @@ -442,11 +592,16 @@ pub fn loadFromPaths( paths: []const []const u8, ) Error!Config { // The merged document is built into its own arena-backed Document so we - // never have to reason about which source layer a given Value came from. + // never have to reason about which source layer a given Value came from — + // EXCEPT the `[extensions]` allow/deny rules, which must retain their + // layer of origin (they are collected separately, not merged/clobbered). const merged = try tvalue.createDocument(allocator); defer merged.deinit(); - for (paths) |path| { + var srcs: SourceLists = .{}; + errdefer srcs.deinit(allocator); + + for (paths, 0..) |path, i| { const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) { error.FileNotFound => continue, error.ReadFailed => return error.ConfigReadFailed, @@ -457,10 +612,24 @@ pub fn loadFromPaths( const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml; defer doc.deinit(); + // Capture this layer's policy rules + source lists before the + // clobbering merge (they accumulate across layers, not clobber). + const layer: u16 = @intCast(i); + try collectRules(allocator, doc.root, layer, &srcs.rules); + try collectStrings(allocator, doc.root, "paths", layer, &srcs.paths); + try collectStrings(allocator, doc.root, "rocks", layer, &srcs.rocks); try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root); } - return resolve(allocator, environ_map, merged.root); + const policy = buildPolicy(allocator, &srcs.rules); + return resolve( + allocator, + environ_map, + merged.root, + policy, + try srcs.paths.toOwnedSlice(allocator), + try srcs.rocks.toOwnedSlice(allocator), + ); } // =========================================================================== @@ -471,7 +640,16 @@ fn resolve( allocator: Allocator, environ_map: *const std.process.Environ.Map, root: *const toml.Value, + /// Extension policy + source lists, pre-built from all layers by the + /// caller. Ownership transfers into the returned Config (or is freed on + /// the error path). + extensions: Policy, + ext_paths: []LayeredStr, + ext_rocks: []LayeredStr, ) Error!Config { + errdefer extensions.deinit(allocator); + errdefer freeLayeredStrs(allocator, ext_paths); + errdefer freeLayeredStrs(allocator, ext_rocks); // Arena owning every auth-related allocation. Moved into the returned // Config on success; deinit'd on any error path before then. var auth_arena = std.heap.ArenaAllocator.init(allocator); @@ -533,12 +711,6 @@ fn resolve( providers.shrinkRetainingCapacity(w); } - // Tool / extension policies. - var tools = try resolvePolicy(allocator, root, "tools"); - errdefer tools.deinit(allocator); - var extensions = try resolvePolicy(allocator, root, "extensions"); - errdefer extensions.deinit(allocator); - // Default model. var default_model: ?[]const u8 = null; errdefer if (default_model) |m| allocator.free(m); @@ -610,8 +782,9 @@ fn resolve( .auth_arena = auth_arena, .default_model = default_model, .default_model_ref = default_model_ref, - .tools = tools, .extensions = extensions, + .ext_paths = ext_paths, + .ext_rocks = ext_rocks, .compaction_keep_verbatim = compaction_keep_verbatim, .compaction_model = compaction_model, .compaction_model_ref = compaction_model_ref, @@ -836,33 +1009,6 @@ fn tomlStr(val: *const toml.Value, key: []const u8) ?[]const u8 { return v.asString(); } -fn resolvePolicy(allocator: Allocator, root: *const toml.Value, key: []const u8) Error!Policy { - var allow: [][]const u8 = &.{}; - var deny: [][]const u8 = &.{}; - errdefer { - for (allow) |p| allocator.free(p); - allocator.free(allow); - for (deny) |p| allocator.free(p); - allocator.free(deny); - } - - if (root.get(key)) |tbl| { - if (tbl.* == .table) { - allow = try stringArray(allocator, tbl, "allow"); - deny = try stringArray(allocator, tbl, "deny"); - } - } - - // A pattern present in both allow and deny is contradictory. - for (allow) |a| { - for (deny) |d| { - if (std.mem.eql(u8, a, d)) return error.PolicyConflict; - } - } - - return .{ .allow = allow, .deny = deny }; -} - fn stringArray(allocator: Allocator, tbl: *const toml.Value, key: []const u8) Error![][]const u8 { const arr_v = tbl.get(key) orelse return &.{}; if (arr_v.* != .array) return &.{}; @@ -921,6 +1067,36 @@ fn emptyEnv(a: Allocator) std.process.Environ.Map { return std.process.Environ.Map.init(a); } +/// Test helper: resolve a single already-parsed document, building its +/// `[extensions]` policy from that one layer (layer 0). +fn resolveDoc( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + root: *const toml.Value, +) Error!Config { + var srcs = try SourceLists.collect(allocator, &.{root}); + errdefer srcs.deinit(allocator); + const policy = buildPolicy(allocator, &srcs.rules); + return resolve( + allocator, + environ_map, + root, + policy, + try srcs.paths.toOwnedSlice(allocator), + try srcs.rocks.toOwnedSlice(allocator), + ); +} + +/// Test helper: build a `Policy` from a `[extensions]` TOML fragment on one +/// layer. Panics on parse error (test-only). +fn policyFromToml(a: Allocator, src: []const u8) Policy { + const doc = parseDoc(a, src) catch unreachable; + defer doc.deinit(); + var rules: std.ArrayList(Rule) = .empty; + collectRules(a, doc.root, 0, &rules) catch unreachable; + return buildPolicy(a, &rules); +} + test "parseModelRef: splits provider and model" { const ref = try parseModelRef("anthropic:claude-sonnet-4-6"); try testing.expectEqualStrings("anthropic", ref.provider); @@ -934,28 +1110,107 @@ test "parseModelRef: rejects malformed refs" { try testing.expectError(error.InvalidModelRef, parseModelRef("a:b:c")); } -test "Policy.permits: empty allow means allow-all minus deny" { +test "Policy.permits: default allow; a deny blocks its matches" { const a = testing.allocator; - var deny = try a.alloc([]const u8, 1); - deny[0] = try a.dupe(u8, "std.shell"); - const policy: Policy = .{ .allow = &.{}, .deny = deny }; + const policy = policyFromToml(a, + \\[extensions] + \\deny = ["std.shell"] + ); defer policy.deinit(a); try testing.expect(policy.permits("std.read")); try testing.expect(!policy.permits("std.shell")); } -test "Policy.permits: allow gates everything not matched" { +test "Policy.permits: whitelist via deny=[**] plus allow carve-outs" { const a = testing.allocator; - var allow = try a.alloc([]const u8, 1); - allow[0] = try a.dupe(u8, "std.*"); - const policy: Policy = .{ .allow = allow, .deny = &.{} }; + const policy = policyFromToml(a, + \\[extensions] + \\deny = ["**"] + \\allow = ["std.*"] + ); defer policy.deinit(a); + // std.* is more specific than ** → permitted; everything else denied. try testing.expect(policy.permits("std.read")); try testing.expect(!policy.permits("other.read")); } +test "Policy.permits: specific allow carves one name out of a broad deny" { + const a = testing.allocator; + const policy = policyFromToml(a, + \\[extensions] + \\deny = ["agent.*"] + \\allow = ["agent.rules"] + ); + defer policy.deinit(a); + + try testing.expect(policy.permits("agent.rules")); // allow more specific + try testing.expect(!policy.permits("agent.skills")); // only the deny matches +} + +test "Policy.permits: deny beats allow at an exact tie" { + const a = testing.allocator; + const policy = policyFromToml(a, + \\[extensions] + \\allow = ["agent.rules"] + \\deny = ["agent.rules"] + ); + defer policy.deinit(a); + + try testing.expect(!policy.permits("agent.rules")); +} + +test "Policy.permits: higher layer's broad rule beats lower layer's specific one" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var pb: [std.fs.max_path_bytes]u8 = undefined; + const tpn = try tmp.dir.realPath(testing.io, &pb); + const tp = pb[0..tpn]; + + // Base pins one specific name; user broadly denies the group. + const base = try std.fs.path.join(a, &.{ tp, "b.toml" }); + defer a.free(base); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.toml", .data = + \\[extensions] + \\allow = ["agent.subagents"] + }); + const user = try std.fs.path.join(a, &.{ tp, "u.toml" }); + defer a.free(user); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "u.toml", .data = + \\[extensions] + \\deny = ["agent.*"] + }); + + var cfg = try loadFromPaths(a, testing.io, &env, &.{ base, user }); + defer cfg.deinit(); + // Higher (user) layer's broad deny wins over base's specific allow. + try testing.expect(!cfg.extensions.permits("agent.subagents")); +} + +test "extensions.paths and rocks parse per layer" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + const src = + \\[extensions] + \\paths = ["./dev/ext"] + \\rocks = ["panto-agent 1.4.2-1"] + ; + var cfg = try loadFromString(a, &env, src); + defer cfg.deinit(); + + try testing.expectEqual(@as(usize, 1), cfg.ext_paths.len); + try testing.expectEqualStrings("./dev/ext", cfg.ext_paths[0].value); + try testing.expectEqual(@as(usize, 1), cfg.ext_rocks.len); + try testing.expectEqualStrings("panto-agent 1.4.2-1", cfg.ext_rocks[0].value); +} + /// Standard api_key provider+auth source for an anthropic provider whose key /// comes from `ANTHROPIC_API_KEY`. Keeps the many model-knob tests terse. const anthropic_env_src = @@ -976,7 +1231,7 @@ test "resolve: api_key provider is dropped when its ${env} key is unset" { const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // Unresolved api_key ⇒ the provider disappears (export the key to get it // back). The auth session itself still exists. @@ -997,7 +1252,7 @@ test "resolve: api_key session resolves when env var is set" { const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqual(@as(usize, 1), cfg.providers.len); const p = cfg.provider("anthropic").?; @@ -1017,7 +1272,7 @@ test "resolve: missing provider auth is an error" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.MissingProviderAuth, resolve(a, &env, doc.root)); + try testing.expectError(error.MissingProviderAuth, resolveDoc(a, &env, doc.root)); } test "resolve: provider naming an unknown auth session is an error" { @@ -1032,7 +1287,7 @@ test "resolve: provider naming an unknown auth session is an error" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.UnknownProviderAuth, resolve(a, &env, doc.root)); + try testing.expectError(error.UnknownProviderAuth, resolveDoc(a, &env, doc.root)); } test "resolve: ${env:VAR} and ${sibling} substitution in auth values" { @@ -1064,7 +1319,7 @@ test "resolve: ${env:VAR} and ${sibling} substitution in auth values" { const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // env substitution resolves the literal key. try testing.expectEqualStrings("sk-from-env", cfg.auth("k").?.resolved_api_key.?); @@ -1085,7 +1340,7 @@ test "resolve: literal key resolves without substitution" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqualStrings("sk-literal", cfg.auth("k").?.resolved_api_key.?); } @@ -1100,7 +1355,7 @@ test "resolve: invalid auth type is an error" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.InvalidAuth, resolve(a, &env, doc.root)); + try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root)); } test "resolve: oauth_device (token dialect) with exchange parses" { @@ -1120,7 +1375,7 @@ test "resolve: oauth_device (token dialect) with exchange parses" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); const oauth = cfg.auth("github_copilot").?.config.oauth_device; // type + dialect inferred/defaulted. @@ -1147,7 +1402,7 @@ test "resolve: oauth_device codex dialect requires a poll url" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.InvalidAuth, resolve(a, &env, doc.root)); + try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root)); } test "resolve: provider extra_headers flow into the built provider config" { @@ -1170,7 +1425,7 @@ test "resolve: provider extra_headers flow into the built provider config" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); const p = cfg.provider("copilot").?; @@ -1199,7 +1454,7 @@ test "resolve: provider model_catalog_name is captured" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqualStrings("github-copilot", cfg.provider("copilot").?.model_catalog_name.?); @@ -1221,7 +1476,7 @@ test "resolve: openai_responses codex dialect maps to internal API style" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqual(APIStyle.openai_codex_responses, cfg.provider("codex").?.style); @@ -1246,7 +1501,7 @@ test "resolve: internal openai_codex_responses style is not user-facing" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.InvalidProvider, resolve(a, &env, doc.root)); + try testing.expectError(error.InvalidProvider, resolveDoc(a, &env, doc.root)); } test "resolve: prompt_cache defaults true and is parsed when set" { @@ -1273,7 +1528,7 @@ test "resolve: prompt_cache defaults true and is parsed when set" { const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // Absent => library default (true). @@ -1326,7 +1581,7 @@ test "buildProviderConfig: prompt_cache flows from provider into anthropic confi const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var defs = models_toml.ModelRegistry.init(a); @@ -1349,23 +1604,26 @@ test "resolve: unknown default-model provider is an error" { const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); + try testing.expectError(error.UnknownDefaultProvider, resolveDoc(a, &env, doc.root)); } -test "resolvePolicy: pattern in both allow and deny is a conflict" { +test "extensions policy: a pattern in both allow and deny resolves to deny" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = - \\[tools] + \\[extensions] \\allow = ["std.*"] \\deny = ["std.*"] ; const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.PolicyConflict, resolve(a, &env, doc.root)); + var cfg = try resolveDoc(a, &env, doc.root); + defer cfg.deinit(); + // No longer an error — deny wins the exact tie. + try testing.expect(!cfg.extensions.permits("std.read")); } test "loadFromPaths: layers merge with tables accumulating and scalars overriding" { @@ -1394,8 +1652,8 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin \\type = "api_key" \\key = "sys-key" \\ - \\[tools] - \\allow = ["std.*"] + \\[extensions] + \\deny = ["std.*"] }); // Project layer: adds an openai provider (table accumulates), and @@ -1432,8 +1690,8 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin try testing.expectEqualStrings("openai", cfg.default_model_ref.?.provider); try testing.expectEqualStrings("gpt", cfg.default_model_ref.?.model); - // tools.allow from the base layer is preserved (no project override). - try testing.expect(cfg.tools.permits("std.read")); + // The base layer's extensions.deny survives (project adds no override). + try testing.expect(!cfg.extensions.permits("std.read")); } test "loadFromPaths: local layer overrides project layer" { @@ -1492,7 +1750,7 @@ test "buildProviderConfig: anthropic ref pulls knobs from model defs" { const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); @@ -1539,7 +1797,7 @@ test "buildProviderConfig: missing alias uses the alias verbatim as wire model" ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); @@ -1560,7 +1818,7 @@ test "buildProviderConfig: anthropic adaptive thinking threaded from model def" const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); @@ -1593,7 +1851,7 @@ test "buildProviderConfig: anthropic missing alias falls back to struct defaults const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); @@ -1615,7 +1873,7 @@ test "buildProviderConfig: unknown provider errors" { defer env.deinit(); const empty = try parseDoc(a, ""); defer empty.deinit(); - var cfg = try resolve(a, &env, empty.root); + var cfg = try resolveDoc(a, &env, empty.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); defer models.deinit(); @@ -1643,7 +1901,7 @@ test "selectModel: default_model wins; single-provider fallback otherwise" { ; const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); @@ -1664,7 +1922,7 @@ test "selectModel: no default and no providers errors" { defer env.deinit(); const empty = try parseDoc(a, ""); defer empty.deinit(); - var cfg = try resolve(a, &env, empty.root); + var cfg = try resolveDoc(a, &env, empty.root); defer cfg.deinit(); var models = models_toml.ModelRegistry.init(a); defer models.deinit(); @@ -1701,7 +1959,7 @@ test "resolve: [compaction] keep_verbatim and model parse" { const doc = try parseDoc(a, src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqual(@as(?u32, 12345), cfg.compaction_keep_verbatim); try testing.expectEqualStrings("anthropic:haiku", cfg.compaction_model.?); @@ -1718,7 +1976,7 @@ test "resolve: [compaction] absent leaves defaults null" { const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); - var cfg = try resolve(a, &env, doc.root); + var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expect(cfg.compaction_keep_verbatim == null); try testing.expect(cfg.compaction_model == null); @@ -1739,5 +1997,5 @@ test "resolve: [compaction] model naming an unresolved provider errors" { const doc = try parseDoc(a, src); defer doc.deinit(); - try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); + try testing.expectError(error.UnknownDefaultProvider, resolveDoc(a, &env, doc.root)); } -- cgit v1.3