diff --git a/packages/gambit-core/mod.ts b/packages/gambit-core/mod.ts index cf133431..e0d0f8e7 100644 --- a/packages/gambit-core/mod.ts +++ b/packages/gambit-core/mod.ts @@ -41,6 +41,20 @@ export type { } from "./src/types.ts"; /** Test deck definition shape. */ export type { TestDeckDefinition } from "./src/types.ts"; +/** Permission declaration shape used by deck metadata and config layers. */ +export type { + PermissionDeclaration, + PermissionDeclarationInput, + PermissionTrace, +} from "./src/permissions.ts"; +/** Permission contract helpers (normalization/intersection/matching). */ +export { + canRunCommand, + canRunPath, + intersectPermissions, + normalizePermissionDeclaration, + resolveEffectivePermissions, +} from "./src/permissions.ts"; /** Check if a value is an explicit end-of-run signal. */ export { isGambitEndSignal } from "./src/runtime.ts"; /** Run a deck and return its execution result. */ diff --git a/packages/gambit-core/src/loader.ts b/packages/gambit-core/src/loader.ts index 67d85e66..63b4727f 100644 --- a/packages/gambit-core/src/loader.ts +++ b/packages/gambit-core/src/loader.ts @@ -6,6 +6,10 @@ import { TOOL_NAME_PATTERN, } from "./constants.ts"; import { isCardDefinition, isDeckDefinition } from "./definitions.ts"; +import { + normalizePermissionDeclaration, + type PermissionDeclarationInput, +} from "./permissions.ts"; import { mergeZodObjects } from "./schema.ts"; import { isMarkdownFile, @@ -120,6 +124,10 @@ function normalizeActionDecks( path: a.path.startsWith("gambit://") || !basePath ? a.path : path.resolve(path.dirname(basePath), a.path), + permissions: normalizePermissionDeclaration( + a.permissions, + basePath ? path.dirname(basePath) : Deno.cwd(), + ), })); } @@ -138,6 +146,12 @@ function normalizeCompanionDecks( path: deck.path.startsWith("gambit://") || !basePath ? deck.path : path.resolve(path.dirname(basePath), deck.path), + permissions: normalizePermissionDeclaration( + (deck as { permissions?: unknown }).permissions as + | PermissionDeclarationInput + | undefined, + basePath ? path.dirname(basePath) : Deno.cwd(), + ), })) as Array; } @@ -216,6 +230,10 @@ async function loadCardInternal( path: resolved, cards: [], ...fragments, + permissions: normalizePermissionDeclaration( + card.permissions, + path.dirname(resolved), + ), }; } @@ -372,6 +390,10 @@ export async function loadDeck( executor, handlers, respond: deck.respond, + permissions: normalizePermissionDeclaration( + deck.permissions, + path.dirname(resolved), + ), }; } diff --git a/packages/gambit-core/src/markdown.test.ts b/packages/gambit-core/src/markdown.test.ts index d22831d7..41242596 100644 --- a/packages/gambit-core/src/markdown.test.ts +++ b/packages/gambit-core/src/markdown.test.ts @@ -272,6 +272,45 @@ Root deck. ); }); +Deno.test("markdown deck resolves deck and action permissions from owner paths", async () => { + const dir = await Deno.makeTempDir(); + const actionDir = path.join(dir, "actions", "do"); + await Deno.mkdir(actionDir, { recursive: true }); + await writeTempDeck( + actionDir, + "PROMPT.md", + `+++ +label = "do" +permissions.read = ["./action-only"] ++++ +Action deck. +`, + ); + + const deckPath = await writeTempDeck( + dir, + "PROMPT.md", + `+++ +label = "root" +permissions.read = ["./workspace"] + +[[actions]] +name = "do_thing" +path = "./actions/do/PROMPT.md" +description = "run do thing" +permissions.read = ["./action-overrides"] ++++ +Root deck. +`, + ); + + const deck = await loadMarkdownDeck(deckPath); + assertEquals(deck.permissions?.read, [path.resolve(dir, "workspace")]); + assertEquals(deck.actionDecks[0].permissions?.read, [ + path.resolve(dir, "action-overrides"), + ]); +}); + Deno.test("markdown execute deck loads module and PROMPT overrides schemas", async () => { const dir = await Deno.makeTempDir(); const execPath = path.join(dir, "exec.ts"); diff --git a/packages/gambit-core/src/markdown.ts b/packages/gambit-core/src/markdown.ts index d800c677..080ae7e3 100644 --- a/packages/gambit-core/src/markdown.ts +++ b/packages/gambit-core/src/markdown.ts @@ -11,6 +11,10 @@ import { } from "./constants.ts"; import { isCardDefinition, isDeckDefinition } from "./definitions.ts"; import { loadCard } from "./loader.ts"; +import { + normalizePermissionDeclaration, + type PermissionDeclarationInput, +} from "./permissions.ts"; import { mergeZodObjects, toJsonSchema } from "./schema.ts"; import { resolveBuiltinSchemaPath } from "./builtins.ts"; import type { @@ -129,6 +133,7 @@ type DeckRef = { label?: string; description?: string; id?: string; + permissions?: PermissionDeclarationInput; }; function normalizeDeckRefs( @@ -157,6 +162,13 @@ function normalizeDeckRefs( if (typeof rec.description !== "string") delete normalized.description; if (typeof rec.label !== "string") delete normalized.label; if (typeof rec.id !== "string") delete normalized.id; + if (rec.permissions !== undefined) { + const parsed = normalizePermissionDeclaration( + rec.permissions as PermissionDeclarationInput, + path.dirname(basePath), + ); + if (parsed) normalized.permissions = parsed; + } if (opts?.requireDescription) { const desc = typeof rec.description === "string" ? rec.description.trim() @@ -338,6 +350,10 @@ export async function loadMarkdownCard( const embeddedCards = replaced.embeds; const respondFlag = Boolean((attrs as { respond?: unknown }).respond); const allowEndFlag = Boolean((attrs as { allowEnd?: unknown }).allowEnd); + const permissions = normalizePermissionDeclaration( + (attrs as { permissions?: PermissionDeclarationInput }).permissions, + path.dirname(resolved), + ); return { kind: "gambit.card", @@ -355,6 +371,7 @@ export async function loadMarkdownCard( resolved, ), cards: embeddedCards, + permissions, contextFragment, responseFragment, inputFragment: contextFragment, @@ -605,6 +622,10 @@ export async function loadMarkdownDeck( const embeddedGraderDecks = allCards.flatMap((card) => card.graderDecks ?? [] ); + const permissions = normalizePermissionDeclaration( + deckMeta.permissions, + path.dirname(resolved), + ); return { kind: "gambit.deck", @@ -638,6 +659,7 @@ export async function loadMarkdownDeck( replaced.respond || allCards.some((c) => c.respond), inlineEmbeds: true, + permissions, }; } diff --git a/packages/gambit-core/src/permissions.test.ts b/packages/gambit-core/src/permissions.test.ts new file mode 100644 index 00000000..ea7cfcf5 --- /dev/null +++ b/packages/gambit-core/src/permissions.test.ts @@ -0,0 +1,178 @@ +import { assert, assertEquals } from "@std/assert"; +import * as path from "@std/path"; +import { + canReadPath, + canRunCommand, + canRunPath, + normalizePermissionDeclaration, + normalizePermissionDeclarationToSet, + resolveEffectivePermissions, +} from "./permissions.ts"; + +Deno.test("permission declaration resolves path grants from owner base dir", () => { + const base = path.resolve("/tmp/workspace/decks/root"); + const permissions = normalizePermissionDeclaration( + { + read: ["../shared/prompts", "./cards"], + run: { paths: ["./bin/tool.sh"], commands: ["deno"] }, + }, + base, + ); + + assert(permissions, "expected permissions to normalize"); + assertEquals(permissions.read, [ + path.resolve("/tmp/workspace/decks/root/cards"), + path.resolve("/tmp/workspace/decks/shared/prompts"), + ]); + assertEquals(permissions.run, { + paths: [path.resolve("/tmp/workspace/decks/root/bin/tool.sh")], + commands: ["deno"], + }); +}); + +Deno.test("root effective permissions apply workspace then declaration then session", () => { + const resolved = resolveEffectivePermissions({ + baseDir: "/workspace/root", + workspace: { + baseDir: "/workspace", + permissions: { + read: ["./decks", "./shared"], + run: { commands: ["deno", "bash"] }, + }, + }, + declaration: { + baseDir: "/workspace/decks/root", + permissions: { + read: ["../../shared"], + run: { commands: ["deno"] }, + }, + }, + session: { + baseDir: "/workspace", + permissions: { + read: ["./shared"], + }, + }, + }); + + assertEquals(resolved.trace.effective.read, ["/workspace/shared"]); + assertEquals(resolved.trace.effective.run, false); + assertEquals(resolved.trace.layers.map((layer) => layer.name), [ + "host", + "workspace", + "declaration", + "session", + ]); +}); + +Deno.test("child permissions are monotonic across parent declaration and reference", () => { + const parent = resolveEffectivePermissions({ + baseDir: "/workspace/decks/parent", + workspace: { + baseDir: "/workspace", + permissions: { + read: ["./shared", "./tools"], + run: { commands: ["deno", "node"] }, + }, + }, + declaration: { + baseDir: "/workspace/decks/parent", + permissions: { read: ["../../shared"], run: { commands: ["deno"] } }, + }, + }); + + const child = resolveEffectivePermissions({ + baseDir: "/workspace/decks/child", + parent: parent.effective, + declaration: { + baseDir: "/workspace/decks/child", + permissions: { + read: ["../../shared", "./private"], + run: { commands: ["deno", "python"] }, + }, + }, + reference: { + baseDir: "/workspace/decks/parent", + permissions: { read: ["../../shared"], run: { commands: ["deno"] } }, + }, + }); + + assertEquals(child.trace.effective.read, ["/workspace/shared"]); + assertEquals(child.trace.effective.run, { paths: [], commands: ["deno"] }); + assertEquals(child.trace.layers.map((layer) => layer.name), [ + "parent", + "declaration", + "reference", + ]); +}); + +Deno.test("child-only inherited permissions use child baseDir for relative checks", () => { + const parent = resolveEffectivePermissions({ + baseDir: "/workspace/decks/parent", + declaration: { + baseDir: "/workspace/decks/parent", + permissions: { + read: ["./local.txt"], + }, + }, + }); + + const child = resolveEffectivePermissions({ + baseDir: "/workspace/decks/child", + parent: parent.effective, + }); + + assertEquals( + canReadPath(child.effective, "./local.txt"), + false, + "child relative checks must resolve from child baseDir, not parent baseDir", + ); +}); + +Deno.test("run grants keep path vs command semantics separate", () => { + const set = normalizePermissionDeclarationToSet( + { + run: { + paths: ["./bin/tool"], + commands: ["tool"], + }, + }, + "/workspace", + ); + assert(set, "expected normalized permission set"); + + assertEquals(canRunPath(set, "/workspace/bin/tool"), true); + assertEquals(canRunPath(set, "/other/tool"), false); + assertEquals(canRunCommand(set, "tool"), true); + assertEquals(canRunCommand(set, "bin/tool"), false); +}); + +Deno.test("run object-form booleans honor all-access semantics", () => { + const pathsTrue = normalizePermissionDeclarationToSet( + { run: { paths: true } }, + "/workspace", + ); + assert(pathsTrue, "expected normalized permission set for paths=true"); + assertEquals(canRunPath(pathsTrue, "/workspace/bin/anything"), true); + assertEquals(canRunCommand(pathsTrue, "anything"), true); + + const commandsTrue = normalizePermissionDeclarationToSet( + { run: { commands: true } }, + "/workspace", + ); + assert(commandsTrue, "expected normalized permission set for commands=true"); + assertEquals(canRunPath(commandsTrue, "/workspace/bin/anything"), true); + assertEquals(canRunCommand(commandsTrue, "anything"), true); +}); + +Deno.test("unspecified kinds deny by default when a layer is provided", () => { + const set = normalizePermissionDeclaration( + { read: ["./one"] }, + "/workspace", + ); + assert(set, "expected normalized permission declaration"); + assertEquals(set.write, false); + assertEquals(set.net, false); + assertEquals(set.env, false); + assertEquals(set.run, false); +}); diff --git a/packages/gambit-core/src/permissions.ts b/packages/gambit-core/src/permissions.ts new file mode 100644 index 00000000..f7debdcb --- /dev/null +++ b/packages/gambit-core/src/permissions.ts @@ -0,0 +1,481 @@ +import * as path from "@std/path"; + +/** + * Deno-native permission kinds supported by Gambit's permission contract. + */ +export const PERMISSION_KINDS = ["read", "write", "run", "net", "env"] as const; +export type PermissionKind = (typeof PERMISSION_KINDS)[number]; + +export type PathPermissionInput = boolean | Array; +export type RunPermissionInput = + | boolean + | Array + | { + paths?: boolean | Array; + commands?: boolean | Array; + }; + +export type PermissionDeclarationInput = Partial<{ + read: PathPermissionInput; + write: PathPermissionInput; + run: RunPermissionInput; + net: PathPermissionInput; + env: PathPermissionInput; +}>; + +export type SerializedRunPermission = false | true | { + paths: Array; + commands: Array; +}; + +export type SerializedPermissionSet = { + read: false | true | Array; + write: false | true | Array; + run: SerializedRunPermission; + net: false | true | Array; + env: false | true | Array; +}; + +export type PermissionDeclaration = SerializedPermissionSet; + +type NormalizedScope = { + all: boolean; + values: Set; +}; + +type NormalizedRunScope = { + all: boolean; + paths: Set; + commands: Set; +}; + +export type NormalizedPermissionSet = { + baseDir: string; + read: NormalizedScope; + write: NormalizedScope; + run: NormalizedRunScope; + net: NormalizedScope; + env: NormalizedScope; +}; + +export type PermissionLayerName = + | "parent" + | "workspace" + | "declaration" + | "reference" + | "session" + | "host"; + +export type PermissionLayerTrace = { + name: PermissionLayerName; + baseDir: string; + requested: SerializedPermissionSet; + effective: SerializedPermissionSet; +}; + +export type PermissionTrace = { + baseDir: string; + effective: SerializedPermissionSet; + layers: Array; +}; + +const DENY_SCOPE: NormalizedScope = { all: false, values: new Set() }; +const DENY_RUN_SCOPE: NormalizedRunScope = { + all: false, + paths: new Set(), + commands: new Set(), +}; + +function cloneScope(scope: NormalizedScope): NormalizedScope { + return { + all: scope.all, + values: new Set(scope.values), + }; +} + +function cloneRunScope(scope: NormalizedRunScope): NormalizedRunScope { + return { + all: scope.all, + paths: new Set(scope.paths), + commands: new Set(scope.commands), + }; +} + +export function cloneNormalizedPermissions( + input: NormalizedPermissionSet, +): NormalizedPermissionSet { + return { + baseDir: input.baseDir, + read: cloneScope(input.read), + write: cloneScope(input.write), + run: cloneRunScope(input.run), + net: cloneScope(input.net), + env: cloneScope(input.env), + }; +} + +function normalizeList( + input: unknown, + kind: PermissionKind, + baseDir: string, + opts?: { resolvePaths?: boolean }, +): NormalizedScope { + if (input === true) return { all: true, values: new Set() }; + if (input === false || input === undefined || input === null) { + return cloneScope(DENY_SCOPE); + } + if (!Array.isArray(input)) { + throw new Error(`permissions.${kind} must be boolean or array`); + } + const values = new Set(); + for (const entry of input) { + if (typeof entry !== "string") { + throw new Error(`permissions.${kind} entries must be strings`); + } + const trimmed = entry.trim(); + if (!trimmed) continue; + const normalized = opts?.resolvePaths + ? path.resolve(baseDir, trimmed) + : trimmed; + values.add(normalized); + } + return { all: false, values }; +} + +function normalizeRun( + input: unknown, + baseDir: string, +): NormalizedRunScope { + if (input === true) { + return { + all: true, + paths: new Set(), + commands: new Set(), + }; + } + if (input === false || input === undefined || input === null) { + return cloneRunScope(DENY_RUN_SCOPE); + } + if (Array.isArray(input)) { + const commands = new Set(); + for (const entry of input) { + if (typeof entry !== "string") { + throw new Error("permissions.run entries must be strings"); + } + const trimmed = entry.trim(); + if (!trimmed) continue; + commands.add(trimmed); + } + return { all: false, paths: new Set(), commands }; + } + if (typeof input !== "object") { + throw new Error("permissions.run must be boolean, array, or object"); + } + const record = input as { + paths?: unknown; + commands?: unknown; + }; + const pathsScope = normalizeList(record.paths, "run", baseDir, { + resolvePaths: true, + }); + const commandsScope = normalizeList(record.commands, "run", baseDir, { + resolvePaths: false, + }); + if (pathsScope.all || commandsScope.all) { + return { + all: true, + paths: new Set(), + commands: new Set(), + }; + } + return { + all: false, + paths: pathsScope.values, + commands: commandsScope.values, + }; +} + +function intersectScope( + a: NormalizedScope, + b: NormalizedScope, +): NormalizedScope { + if (a.all) return cloneScope(b); + if (b.all) return cloneScope(a); + const values = new Set(); + for (const value of a.values) { + if (b.values.has(value)) values.add(value); + } + return { all: false, values }; +} + +function intersectRun( + a: NormalizedRunScope, + b: NormalizedRunScope, +): NormalizedRunScope { + if (a.all) return cloneRunScope(b); + if (b.all) return cloneRunScope(a); + + const paths = new Set(); + for (const value of a.paths) { + if (b.paths.has(value)) paths.add(value); + } + const commands = new Set(); + for (const value of a.commands) { + if (b.commands.has(value)) commands.add(value); + } + + return { + all: false, + paths, + commands, + }; +} + +/** + * Returns an allow-all permission set anchored to `baseDir`. + */ +export function allowAllPermissions(baseDir: string): NormalizedPermissionSet { + return { + baseDir, + read: { all: true, values: new Set() }, + write: { all: true, values: new Set() }, + run: { all: true, paths: new Set(), commands: new Set() }, + net: { all: true, values: new Set() }, + env: { all: true, values: new Set() }, + }; +} + +function normalizePermissionSet( + input: PermissionDeclarationInput, + baseDir: string, +): NormalizedPermissionSet { + return { + baseDir, + read: normalizeList(input.read, "read", baseDir, { resolvePaths: true }), + write: normalizeList(input.write, "write", baseDir, { + resolvePaths: true, + }), + run: normalizeRun(input.run, baseDir), + net: normalizeList(input.net, "net", baseDir), + env: normalizeList(input.env, "env", baseDir), + }; +} + +/** + * Normalizes a permission declaration to a serializable, deterministic shape. + * + * Relative path grants are resolved against `baseDir`. + */ +export function normalizePermissionDeclaration( + input: PermissionDeclarationInput | undefined, + baseDir: string, +): PermissionDeclaration | undefined { + if (!input) return undefined; + return serializePermissions(normalizePermissionSet(input, baseDir)); +} + +/** + * Normalizes a declaration to the internal set form used during intersection. + */ +export function normalizePermissionDeclarationToSet( + input: PermissionDeclarationInput | undefined, + baseDir: string, +): NormalizedPermissionSet | undefined { + if (!input) return undefined; + return normalizePermissionSet(input, baseDir); +} + +/** + * Serializes an internal normalized permission set for traces/persistence. + */ +export function serializePermissions( + set: NormalizedPermissionSet, +): SerializedPermissionSet { + const serializeScope = ( + scope: NormalizedScope, + ): false | true | Array => { + if (scope.all) return true; + if (scope.values.size === 0) return false; + return Array.from(scope.values).sort(); + }; + + const serializeRunScope = ( + scope: NormalizedRunScope, + ): SerializedRunPermission => { + if (scope.all) return true; + if (scope.paths.size === 0 && scope.commands.size === 0) { + return false; + } + return { + paths: Array.from(scope.paths).sort(), + commands: Array.from(scope.commands).sort(), + }; + }; + + return { + read: serializeScope(set.read), + write: serializeScope(set.write), + run: serializeRunScope(set.run), + net: serializeScope(set.net), + env: serializeScope(set.env), + }; +} + +/** + * Computes the monotonic intersection between two permission sets. + * + * `baseDir` controls how relative checks (`canReadPath`/etc) are evaluated for + * the returned set. + */ +export function intersectPermissions( + parent: NormalizedPermissionSet, + next: NormalizedPermissionSet, + baseDir: string, +): NormalizedPermissionSet { + return { + baseDir, + read: intersectScope(parent.read, next.read), + write: intersectScope(parent.write, next.write), + run: intersectRun(parent.run, next.run), + net: intersectScope(parent.net, next.net), + env: intersectScope(parent.env, next.env), + }; +} + +/** + * Resolves effective permissions and emits a layer-by-layer permission trace. + * + * Layer precedence: + * 1. `parent` (or host allow-all for roots) + * 2. `workspace` (root only) + * 3. `declaration` (deck/card declaration) + * 4. `reference` (parent reference override) + * 5. `session` (root only) + */ +export function resolveEffectivePermissions(args: { + baseDir: string; + parent?: NormalizedPermissionSet; + workspace?: { baseDir: string; permissions: PermissionDeclarationInput }; + declaration?: { baseDir: string; permissions: PermissionDeclarationInput }; + reference?: { baseDir: string; permissions: PermissionDeclarationInput }; + session?: { baseDir: string; permissions: PermissionDeclarationInput }; +}): { + effective: NormalizedPermissionSet; + trace: PermissionTrace; +} { + const layers: Array = []; + let effective = args.parent + ? { + ...cloneNormalizedPermissions(args.parent), + // Rebase relative-path checks to the current invocation scope. + baseDir: args.baseDir, + } + : allowAllPermissions(args.baseDir); + + if (args.parent) { + layers.push({ + name: "parent", + baseDir: args.parent.baseDir, + requested: serializePermissions(args.parent), + effective: serializePermissions(effective), + }); + } else { + layers.push({ + name: "host", + baseDir: args.baseDir, + requested: serializePermissions(effective), + effective: serializePermissions(effective), + }); + } + + const applyLayer = ( + name: PermissionLayerName, + input: + | { baseDir: string; permissions: PermissionDeclarationInput } + | undefined, + ) => { + if (!input) return; + const requested = normalizePermissionSet(input.permissions, input.baseDir); + effective = intersectPermissions(effective, requested, args.baseDir); + layers.push({ + name, + baseDir: input.baseDir, + requested: serializePermissions(requested), + effective: serializePermissions(effective), + }); + }; + + if (!args.parent) { + applyLayer("workspace", args.workspace); + } + applyLayer("declaration", args.declaration); + applyLayer("reference", args.reference); + if (!args.parent) { + applyLayer("session", args.session); + } + + return { + effective, + trace: { + baseDir: args.baseDir, + effective: serializePermissions(effective), + layers, + }, + }; +} + +function matchScope(scope: NormalizedScope, target: string): boolean { + if (scope.all) return true; + return scope.values.has(target); +} + +/** + * Returns whether `targetPath` is readable under `set`. + * + * Relative paths are resolved against `set.baseDir`. + */ +export function canReadPath( + set: NormalizedPermissionSet, + targetPath: string, +): boolean { + return matchScope(set.read, path.resolve(set.baseDir, targetPath)); +} + +/** + * Returns whether `targetPath` is writable under `set`. + * + * Relative paths are resolved against `set.baseDir`. + */ +export function canWritePath( + set: NormalizedPermissionSet, + targetPath: string, +): boolean { + return matchScope(set.write, path.resolve(set.baseDir, targetPath)); +} + +/** + * Returns whether `targetPath` is executable via run-path grants. + * + * Relative paths are resolved against `set.baseDir`. + */ +export function canRunPath( + set: NormalizedPermissionSet, + targetPath: string, +): boolean { + if (set.run.all) return true; + const resolved = path.resolve(set.baseDir, targetPath); + return set.run.paths.has(resolved); +} + +/** + * Returns whether `commandName` is executable via run-command grants. + * + * This check intentionally does not apply basename/path fallback semantics. + */ +export function canRunCommand( + set: NormalizedPermissionSet, + commandName: string, +): boolean { + if (set.run.all) return true; + return set.run.commands.has(commandName); +} diff --git a/packages/gambit-core/src/runtime.test.ts b/packages/gambit-core/src/runtime.test.ts index 6ebbcc5a..24c4e8dc 100644 --- a/packages/gambit-core/src/runtime.test.ts +++ b/packages/gambit-core/src/runtime.test.ts @@ -1193,6 +1193,9 @@ Deno.test("run.start traces input and gambit_context payload", async () => { inputSchema: z.object({ question: z.string() }), outputSchema: z.string(), modelParams: { model: "dummy-model" }, + permissions: { + read: ["./docs"], + }, }); `, ); @@ -1222,6 +1225,12 @@ Deno.test("run.start traces input and gambit_context payload", async () => { >; assertEquals(start.deckPath, deckPath); assertEquals(start.input, input); + assert(start.permissions, "expected permissions trace on run.start"); + assertEquals(start.permissions.layers.map((layer) => layer.name), [ + "host", + "declaration", + ]); + assertEquals(start.permissions.effective.read, [path.resolve(dir, "docs")]); const initCall = traces.find((t) => t.type === "tool.call" && t.name === "gambit_context" diff --git a/packages/gambit-core/src/runtime.ts b/packages/gambit-core/src/runtime.ts index 3e1fe868..a9004dab 100644 --- a/packages/gambit-core/src/runtime.ts +++ b/packages/gambit-core/src/runtime.ts @@ -9,6 +9,7 @@ import { GAMBIT_TOOL_RESPOND, } from "./constants.ts"; import { loadDeck } from "./loader.ts"; +import { resolveEffectivePermissions } from "./permissions.ts"; import { assertZodSchema, toJsonSchema, validateWithSchema } from "./schema.ts"; import type { ExecutionContext, @@ -23,6 +24,11 @@ import type { ToolDefinition, } from "./types.ts"; import type { MessageRef, SavedState } from "./state.ts"; +import type { + NormalizedPermissionSet, + PermissionDeclarationInput, + PermissionTrace, +} from "./permissions.ts"; export type GambitEndSignal = { __gambitEnd: true; @@ -77,6 +83,13 @@ type RunOptions = { onStreamText?: (chunk: string) => void; allowRootStringInput?: boolean; responsesMode?: boolean; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; + parentPermissions?: NormalizedPermissionSet; + referencePermissions?: PermissionDeclarationInput; + referencePermissionsBaseDir?: string; }; export async function runDeck(opts: RunOptions): Promise { @@ -93,6 +106,31 @@ export async function runDeck(opts: RunOptions): Promise { const runId = opts.runId ?? opts.state?.runId ?? randomId("run"); const deck = await loadDeck(opts.path); + const permissions = resolveEffectivePermissions({ + baseDir: path.dirname(deck.path), + parent: opts.parentPermissions, + workspace: opts.workspacePermissions + ? { + baseDir: opts.workspacePermissionsBaseDir ?? path.dirname(deck.path), + permissions: opts.workspacePermissions, + } + : undefined, + declaration: deck.permissions + ? { baseDir: path.dirname(deck.path), permissions: deck.permissions } + : undefined, + reference: opts.referencePermissions + ? { + baseDir: opts.referencePermissionsBaseDir ?? path.dirname(deck.path), + permissions: opts.referencePermissions, + } + : undefined, + session: opts.sessionPermissions + ? { + baseDir: opts.sessionPermissionsBaseDir ?? Deno.cwd(), + permissions: opts.sessionPermissions, + } + : undefined, + }); const deckGuardrails = deck.guardrails ?? {}; const effectiveGuardrails: Guardrails = { ...guardrails, @@ -124,6 +162,7 @@ export async function runDeck(opts: RunOptions): Promise { input: validatedInput as unknown as import("./types.ts").JSONValue, initialUserMessage: opts .initialUserMessage as unknown as import("./types.ts").JSONValue, + permissions: permissions.trace, }); } try { @@ -148,6 +187,12 @@ export async function runDeck(opts: RunOptions): Promise { onStateUpdate: opts.onStateUpdate, onStreamText: opts.onStreamText, responsesMode: opts.responsesMode, + permissions: permissions.effective, + permissionsTrace: permissions.trace, + workspacePermissions: opts.workspacePermissions, + workspacePermissionsBaseDir: opts.workspacePermissionsBaseDir, + sessionPermissions: opts.sessionPermissions, + sessionPermissionsBaseDir: opts.sessionPermissionsBaseDir, }); } @@ -171,6 +216,12 @@ export async function runDeck(opts: RunOptions): Promise { stream: opts.stream, onStreamText: opts.onStreamText, responsesMode: opts.responsesMode, + permissions: permissions.effective, + permissionsTrace: permissions.trace, + workspacePermissions: opts.workspacePermissions, + workspacePermissionsBaseDir: opts.workspacePermissionsBaseDir, + sessionPermissions: opts.sessionPermissions, + sessionPermissionsBaseDir: opts.sessionPermissionsBaseDir, }); } finally { if (shouldEmitRun) { @@ -535,6 +586,12 @@ type RuntimeCtxBase = { onStateUpdate?: (state: SavedState) => void; onStreamText?: (chunk: string) => void; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + permissionsTrace: PermissionTrace; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }; async function runComputeDeck(ctx: RuntimeCtxBase): Promise { @@ -600,6 +657,11 @@ async function runComputeDeck(ctx: RuntimeCtxBase): Promise { responsesMode: ctx.responsesMode, initialUserMessage: undefined, inputProvided: true, + parentPermissions: ctx.permissions, + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); }, fail: (opts) => { @@ -658,6 +720,11 @@ async function runLlmDeck( onStreamText: ctx.onStreamText, pushMessages: (msgs) => messages.push(...msgs.map(sanitizeMessage)), responsesMode: ctx.responsesMode, + permissions: ctx.permissions, + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); let streamingBuffer = ""; let streamingCommitted = false; @@ -733,6 +800,7 @@ async function runLlmDeck( deckPath: deck.path, actionCallId, parentActionCallId: ctx.parentActionCallId, + permissions: ctx.permissionsTrace, }); let passes = 0; try { @@ -1025,6 +1093,17 @@ async function runLlmDeck( continue; } + const actionRef = deck.actionDecks.find((a) => a.name === call.name); + const actionPermissions = resolveEffectivePermissions({ + baseDir: path.dirname(deck.path), + parent: ctx.permissions, + reference: actionRef?.permissions + ? { + baseDir: path.dirname(deck.path), + permissions: actionRef.permissions, + } + : undefined, + }); ctx.trace?.({ type: "action.start", runId, @@ -1032,6 +1111,7 @@ async function runLlmDeck( name: call.name, path: call.name, parentActionCallId: actionCallId, + permissions: actionPermissions.trace, }); ctx.trace?.({ type: "tool.call", @@ -1058,6 +1138,11 @@ async function runLlmDeck( inputProvided: true, idle: idleController, responsesMode: ctx.responsesMode, + permissions: ctx.permissions, + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); ctx.trace?.({ type: "tool.result", @@ -1215,6 +1300,11 @@ async function handleToolCall( inputProvided?: boolean; idle?: IdleController; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }, ): Promise { const action = ctx.parentDeck.actionDecks.find((a) => a.name === call.name); @@ -1286,6 +1376,13 @@ async function handleToolCall( onStreamText: ctx.onStreamText, responsesMode: ctx.responsesMode, initialUserMessage: undefined, + parentPermissions: ctx.permissions, + referencePermissions: action.permissions, + referencePermissionsBaseDir: path.dirname(ctx.parentDeck.path), + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); return { ok: true, result }; } catch (err) { @@ -1317,6 +1414,11 @@ async function handleToolCall( onStreamText: ctx.onStreamText, responsesMode: ctx.responsesMode, initialUserMessage: undefined, + permissions: ctx.permissions, + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); if (envelope.length) { extraMessages.push(...envelope.map(sanitizeMessage)); @@ -1402,6 +1504,11 @@ async function handleToolCall( onStreamText: ctx.onStreamText, responsesMode: ctx.responsesMode, initialUserMessage: undefined, + permissions: ctx.permissions, + workspacePermissions: ctx.workspacePermissions, + workspacePermissionsBaseDir: ctx.workspacePermissionsBaseDir, + sessionPermissions: ctx.sessionPermissions, + sessionPermissionsBaseDir: ctx.sessionPermissionsBaseDir, }); if (envelope.length) { extraMessages.push(...envelope.map(sanitizeMessage)); @@ -1482,6 +1589,11 @@ async function runBusyHandler(args: { onStreamText?: (chunk: string) => void; initialUserMessage?: unknown; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }): Promise> { try { const input = { @@ -1511,6 +1623,11 @@ async function runBusyHandler(args: { responsesMode: args.responsesMode, initialUserMessage: args.initialUserMessage, inputProvided: true, + parentPermissions: args.permissions, + workspacePermissions: args.workspacePermissions, + workspacePermissionsBaseDir: args.workspacePermissionsBaseDir, + sessionPermissions: args.sessionPermissions, + sessionPermissionsBaseDir: args.sessionPermissionsBaseDir, }); const elapsedMs = Math.floor(args.elapsedMs); let message: string | undefined; @@ -1555,6 +1672,11 @@ function createIdleController(args: { onStreamText?: (chunk: string) => void; pushMessages: (msgs: Array) => void; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }): IdleController { if (!args.cfg?.path) { return { @@ -1601,6 +1723,11 @@ function createIdleController(args: { stream: args.stream, onStreamText: args.onStreamText, responsesMode: args.responsesMode, + permissions: args.permissions, + workspacePermissions: args.workspacePermissions, + workspacePermissionsBaseDir: args.workspacePermissionsBaseDir, + sessionPermissions: args.sessionPermissions, + sessionPermissionsBaseDir: args.sessionPermissionsBaseDir, }); if (envelope.length) args.pushMessages(envelope.map(sanitizeMessage)); } catch { @@ -1651,6 +1778,11 @@ async function runIdleHandler(args: { stream?: boolean; onStreamText?: (chunk: string) => void; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }): Promise> { try { const input = { @@ -1679,6 +1811,11 @@ async function runIdleHandler(args: { responsesMode: args.responsesMode, initialUserMessage: undefined, inputProvided: true, + parentPermissions: args.permissions, + workspacePermissions: args.workspacePermissions, + workspacePermissionsBaseDir: args.workspacePermissionsBaseDir, + sessionPermissions: args.sessionPermissions, + sessionPermissionsBaseDir: args.sessionPermissionsBaseDir, }); const elapsedMs = Math.floor(args.elapsedMs); let message: string | undefined; @@ -1724,6 +1861,11 @@ async function maybeHandleError(args: { stream?: boolean; onStreamText?: (chunk: string) => void; responsesMode?: boolean; + permissions: NormalizedPermissionSet; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; + sessionPermissions?: PermissionDeclarationInput; + sessionPermissionsBaseDir?: string; }; action: { name: string; path: string; label?: string; description?: string }; }): Promise { @@ -1762,6 +1904,11 @@ async function maybeHandleError(args: { responsesMode: args.ctx.responsesMode, initialUserMessage: undefined, inputProvided: true, + parentPermissions: args.ctx.permissions, + workspacePermissions: args.ctx.workspacePermissions, + workspacePermissionsBaseDir: args.ctx.workspacePermissionsBaseDir, + sessionPermissions: args.ctx.sessionPermissions, + sessionPermissionsBaseDir: args.ctx.sessionPermissionsBaseDir, }); const parsed = typeof handlerOutput === "object" && handlerOutput !== null diff --git a/packages/gambit-core/src/types.ts b/packages/gambit-core/src/types.ts index 9e4bc251..61406066 100644 --- a/packages/gambit-core/src/types.ts +++ b/packages/gambit-core/src/types.ts @@ -1,5 +1,10 @@ import type { ZodTypeAny } from "zod"; import type { SavedState } from "./state.ts"; +import type { + PermissionDeclaration, + PermissionDeclarationInput, + PermissionTrace, +} from "./permissions.ts"; export type JSONValue = | string @@ -46,6 +51,7 @@ export type DeckReferenceDefinition = { label?: Label; description?: string; id?: string; + permissions?: PermissionDeclarationInput; }; export type ActionDeckDefinition = DeckReferenceDefinition & { @@ -103,6 +109,7 @@ export type BaseDefinition = { actionDecks?: ReadonlyArray; testDecks?: ReadonlyArray; graderDecks?: ReadonlyArray; + permissions?: PermissionDeclarationInput; guardrails?: Partial; }; @@ -349,6 +356,7 @@ export type LoadedCard = WithDeckRefs & { actions: Array; testDecks: Array; graderDecks: Array; + permissions?: PermissionDeclaration; }; export type LoadedDeck = WithDeckRefs & { @@ -364,6 +372,7 @@ export type LoadedDeck = WithDeckRefs & { executor?: DeckExecutor; guardrails?: Partial; inlineEmbeds?: boolean; + permissions?: PermissionDeclaration; }; export type ToolCallResult = { @@ -382,6 +391,7 @@ export type TraceEvent = deckPath?: string; input?: JSONValue; initialUserMessage?: JSONValue; + permissions?: PermissionTrace; } | { type: "message.user"; @@ -398,6 +408,7 @@ export type TraceEvent = deckPath: string; actionCallId: string; parentActionCallId?: string; + permissions?: PermissionTrace; } | { type: "deck.end"; @@ -413,6 +424,7 @@ export type TraceEvent = name: string; path: string; parentActionCallId?: string; + permissions?: PermissionTrace; } | { type: "action.end"; diff --git a/src/cli.ts b/src/cli.ts index 80c993fd..1671beb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { import { createModelAliasResolver, loadProjectConfig, + resolveWorkspacePermissions, } from "./project_config.ts"; const logger = console; @@ -255,6 +256,9 @@ async function main() { const modelAliasResolver = createModelAliasResolver( projectConfig?.config, ); + const workspacePermissions = resolveWorkspacePermissions( + projectConfig?.config, + ); const warnedMissingAliases = new Set(); const expandModelCandidates = ( model: string | Array | undefined, @@ -793,6 +797,8 @@ async function main() { stream: args.stream, statePath: args.statePath, responsesMode, + workspacePermissions, + workspacePermissionsBaseDir: projectConfig?.root, }); } catch (err) { logger.error(err instanceof Error ? err.message : String(err)); diff --git a/src/commands/run.ts b/src/commands/run.ts index d9bc7006..98d1322e 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,6 +1,7 @@ import { isGambitEndSignal, runDeck } from "@bolt-foundry/gambit-core"; import { loadState, saveState } from "@bolt-foundry/gambit-core"; import type { ModelProvider } from "@bolt-foundry/gambit-core"; +import type { PermissionDeclarationInput } from "@bolt-foundry/gambit-core"; import { enrichStateMeta } from "../cli_utils.ts"; const logger = console; @@ -19,6 +20,8 @@ export async function handleRunCommand(opts: { stream?: boolean; statePath?: string; responsesMode?: boolean; + workspacePermissions?: PermissionDeclarationInput; + workspacePermissionsBaseDir?: string; }) { const state = opts.statePath ? loadState(opts.statePath) : undefined; const onStateUpdate = opts.statePath @@ -41,6 +44,8 @@ export async function handleRunCommand(opts: { state, onStateUpdate, responsesMode: opts.responsesMode, + workspacePermissions: opts.workspacePermissions, + workspacePermissionsBaseDir: opts.workspacePermissionsBaseDir, }); if (isGambitEndSignal(result)) { diff --git a/src/project_config.test.ts b/src/project_config.test.ts index 3dc765ff..a422514e 100644 --- a/src/project_config.test.ts +++ b/src/project_config.test.ts @@ -3,6 +3,7 @@ import * as path from "@std/path"; import { createModelAliasResolver, loadProjectConfig, + resolveWorkspacePermissions, } from "./project_config.ts"; Deno.test("loadProjectConfig finds the nearest gambit.toml", async () => { @@ -88,3 +89,18 @@ Deno.test("createModelAliasResolver flags missing aliases", () => { assertEquals(resolution.missingAlias, true); assertEquals(resolution.model, "nonexistent"); }); + +Deno.test("resolveWorkspacePermissions returns workspace ceiling", () => { + const permissions = resolveWorkspacePermissions({ + workspace: { + permissions: { + read: ["./decks"], + run: { commands: ["deno"] }, + }, + }, + }); + assertEquals(permissions, { + read: ["./decks"], + run: { commands: ["deno"] }, + }); +}); diff --git a/src/project_config.ts b/src/project_config.ts index cfcb2b12..75932242 100644 --- a/src/project_config.ts +++ b/src/project_config.ts @@ -1,5 +1,6 @@ import { parse as parseToml } from "@std/toml"; import * as path from "@std/path"; +import type { PermissionDeclarationInput } from "@bolt-foundry/gambit-core"; export type WorkspaceConfig = { decks?: string; @@ -7,6 +8,7 @@ export type WorkspaceConfig = { graders?: string; tests?: string; schemas?: string; + permissions?: PermissionDeclarationInput; }; export type ModelAliasConfig = { @@ -147,3 +149,11 @@ export function createModelAliasResolver( return { model, applied: false, missingAlias }; }; } + +export function resolveWorkspacePermissions( + config?: GambitConfig | null, +): PermissionDeclarationInput | undefined { + const raw = config?.workspace?.permissions; + if (!isPlainObject(raw)) return undefined; + return raw as PermissionDeclarationInput; +} diff --git a/src/trace.ts b/src/trace.ts index 15cd8957..8ba79f1b 100644 --- a/src/trace.ts +++ b/src/trace.ts @@ -18,6 +18,12 @@ export function makeConsoleTracer(): (event: TraceEvent) => void { const now = () => performance.now(); const fmtMs = (start?: number) => start !== undefined ? ` elapsed_ms=${Math.round(now() - start)}` : ""; + const fmtPermissions = ( + permissions?: { effective?: unknown }, + ) => + permissions?.effective !== undefined + ? ` permissions=${JSON.stringify(permissions.effective)}` + : ""; return (event: TraceEvent) => { switch (event.type) { @@ -36,7 +42,7 @@ export function makeConsoleTracer(): (event: TraceEvent) => void { event.input !== undefined ? ` input=${JSON.stringify(event.input)}` : "" - }`, + }${fmtPermissions(event.permissions)}`, ); break; case "run.end": { @@ -56,7 +62,9 @@ export function makeConsoleTracer(): (event: TraceEvent) => void { case "deck.start": started.set(event.actionCallId, now()); logger.log( - `[trace] deck.start runId=${event.runId} actionCallId=${event.actionCallId} deck=${event.deckPath}`, + `[trace] deck.start runId=${event.runId} actionCallId=${event.actionCallId} deck=${event.deckPath}${ + fmtPermissions(event.permissions) + }`, ); break; case "deck.end": { @@ -72,7 +80,9 @@ export function makeConsoleTracer(): (event: TraceEvent) => void { case "action.start": started.set(event.actionCallId, now()); logger.log( - `[trace] action.start runId=${event.runId} actionCallId=${event.actionCallId} name=${event.name} path=${event.path}`, + `[trace] action.start runId=${event.runId} actionCallId=${event.actionCallId} name=${event.name} path=${event.path}${ + fmtPermissions(event.permissions) + }`, ); break; case "action.end": {