Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/gambit-core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
22 changes: 22 additions & 0 deletions packages/gambit-core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
),
}));
}

Expand All @@ -138,6 +146,12 @@ function normalizeCompanionDecks<T extends { path: string }>(
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<T>;
}

Expand Down Expand Up @@ -216,6 +230,10 @@ async function loadCardInternal(
path: resolved,
cards: [],
...fragments,
permissions: normalizePermissionDeclaration(
card.permissions,
path.dirname(resolved),
),
};
}

Expand Down Expand Up @@ -372,6 +390,10 @@ export async function loadDeck(
executor,
handlers,
respond: deck.respond,
permissions: normalizePermissionDeclaration(
deck.permissions,
path.dirname(resolved),
),
};
}

Expand Down
39 changes: 39 additions & 0 deletions packages/gambit-core/src/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions packages/gambit-core/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -129,6 +133,7 @@ type DeckRef = {
label?: string;
description?: string;
id?: string;
permissions?: PermissionDeclarationInput;
};

function normalizeDeckRefs<T extends DeckRef>(
Expand Down Expand Up @@ -157,6 +162,13 @@ function normalizeDeckRefs<T extends DeckRef>(
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()
Expand Down Expand Up @@ -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",
Expand All @@ -355,6 +371,7 @@ export async function loadMarkdownCard(
resolved,
),
cards: embeddedCards,
permissions,
contextFragment,
responseFragment,
inputFragment: contextFragment,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -638,6 +659,7 @@ export async function loadMarkdownDeck(
replaced.respond ||
allCards.some((c) => c.respond),
inlineEmbeds: true,
permissions,
};
}

Expand Down
178 changes: 178 additions & 0 deletions packages/gambit-core/src/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading