summaryrefslogtreecommitdiff
path: root/src/config_file.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-01 15:36:20 -0600
committert <t@tjp.lol>2026-07-01 15:37:04 -0600
commit800b2c4b6115845f6bf15d90484b3e80e81a606f (patch)
tree914981ad5c49e8941280d015923b7c3143463d64 /src/config_file.zig
parentef7c37c3423ceb896f20676525307f15d6e927b4 (diff)
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<paths<dir. - extensions.paths adds extra scan dirs; extensions.rocks loads luarocks packages as extension sources (require-as-entries). Startup installs only missing rocks (no per-launch network hit); panto update force- (re)installs every configured rock. deny is a feature toggle, not a security boundary: rocks is where registry code enters, so the pin list is the trust boundary. Rock integrity (first-party GPG signing + --verify) is designed but not yet wired; until then rocks pins which version, not which bytes. Breaking: [tools] is removed; extensions must return entries. Built-in tools and the wc example keep working via the sugar tool form.
Diffstat (limited to 'src/config_file.zig')
-rw-r--r--src/config_file.zig468
1 files changed, 363 insertions, 105 deletions
diff --git a/src/config_file.zig b/src/config_file.zig
index 5bd2a51..deca908 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -12,11 +12,17 @@
//! `project` layer without committing them.
//!
//! Merge semantics: **tables merge recursively; scalars and arrays from a
-//! higher-precedence layer overwrite wholesale.** A project file that sets
-//! `tools.deny = [...]` replaces the array entirely — it does not append to
-//! a user-level `tools.deny`. Tables (notably `providers`) accumulate: a
-//! provider defined only at the base layer survives even when the project
-//! layer adds a different provider.
+//! higher-precedence layer overwrite wholesale.** Tables (notably `providers`)
+//! accumulate: a provider defined only at the base layer survives even when
+//! the project layer adds a different provider.
+//!
+//! **Exception — `[extensions]` allow/deny.** These are NOT clobbered across
+//! layers. Every layer's rules are retained, tagged with their layer, and
+//! resolved by "last matching rule wins" after sorting by
+//! `(layer, specificity, deny-last)` — see `Policy`. So a higher layer's rule
+//! beats a lower layer's, within a layer the more-specific pattern wins, and
+//! `deny` wins an exact tie. This lets a base layer carve one name out of an
+//! otherwise-denied group without a higher layer's unrelated rule erasing it.
//!
//! After merge, the document is resolved into a `Config`:
//!
@@ -40,9 +46,10 @@
//! falls back to the provider name). `extra_headers` (a table of string
//! headers) rides on the model request.
//! - `defaults.model` is parsed as `<provider_name>:<model_alias>`.
-//! - `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 `<model_alias>` 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.<key>` 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 `<provider>:<alias>` 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 `<provider>:<alias>` 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));
}