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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased] — Phase 2

### S13 `phase2-config-authority-refs` — sandbox config moves to refs

**BREAKING (in-place v1alpha1 schema edit; pre-release, no conversion webhook).**

The `ClawSandbox` spec no longer carries inline inference or tool-policy
configuration. Instead, the sandbox holds same-namespace references to
sibling `InferencePolicy` and `ToolPolicy` CRDs which become the single
source of truth.

Schema changes (`controller/src/crd.rs`,
`deploy/helm/azureclaw/templates/crd.yaml`):

- `spec.inference: InferenceConfig` → **removed**.
- `spec.inferenceRef: { name: string }` → **new, required**. References
a sibling `InferencePolicy` CR. Cross-namespace refs are not supported
(same-namespace only — security invariant).
- `spec.governance.toolPolicy: string` (profile name) → **removed**.
- `spec.governance.toolPolicyRef: { name: string }` → **new**. Required
when `governance.enabled=true` (CEL-enforced). References a sibling
`ToolPolicy` CR; the resolved CR's `metadata.name` doubles as the AGT
policy profile carried into the sandbox via `AGT_POLICY_PROFILE`.
- New status reasons `InferencePolicyNotFound`, `ToolPolicyNotFound` —
emitted on `Degraded` when a referenced CR is missing.

CLI updates (`cli/src/commands/{up/sandbox_bringup,add,attest}.ts`,
`cli/src/migrate/from_kagent.ts`, new `cli/src/refs.ts`): the `azureclaw
up` and `azureclaw add` commands now emit a multi-doc bundle
(`InferencePolicy` + optional `ToolPolicy` + `ClawSandbox`) and apply
all three in one shot. Naming convention: `<sandbox>-inference` and
`<sandbox>-toolpolicy`, DNS-1123 truncated to 63 chars. The
`from-kagent` migrator now always emits an `<sandbox>-inference`
InferencePolicy (preserving any kagent `modelConfig` provenance) and
adds an aggregator `<sandbox>-toolpolicy` whenever governance is on.

Examples (`examples/basic-agent/`, `examples/confidential-agent/`,
`examples/telegram-agent/`, `examples/demo-clawshield/*-agent.yaml`)
and e2e fixtures (`tests/e2e/run.sh`,
`tests/compat/fixtures/null-provider-*.yaml`) updated to the
two-doc-per-sandbox shape.

### S12.d — SignerPolicy ConfigMap (Fulcio issuer + SAN allowlist)

- New `controller/src/signer_policy.rs` — cluster-scoped
Expand Down
37 changes: 17 additions & 20 deletions cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,8 @@ function buildSandboxManifest(name: string, options: AddOptions) {
allowPrivilegeEscalation: false,
writablePaths: ["/sandbox", "/tmp"],
},
inference: {
provider: "azure-ai-foundry",
model: options.model,
contentSafety: true,
promptShields: true,
tokenBudget: {
daily: parseInt(options.tokenBudgetDaily) || 0,
perRequest: parseInt(options.tokenBudgetPerRequest) || 0,
},
inferenceRef: {
name: `${name}-inference`,
},
networkPolicy: {
defaultDeny: true,
Expand Down Expand Up @@ -108,7 +101,7 @@ function buildSandboxManifest(name: string, options: AddOptions) {
if (options.governance) {
(sandbox.spec as Record<string, unknown>).governance = {
enabled: true,
toolPolicy: options.policyProfile || "default",
toolPolicyRef: { name: `${name}-toolpolicy` },
trustThreshold: parseInt(options.trustThreshold) || 500,
};
}
Expand Down Expand Up @@ -190,14 +183,16 @@ describe("ClawSandbox manifest generation", () => {
const spec = manifest.spec as any;
expect(spec.runtime.kind).toBe("OpenClaw");
expect(spec.runtime.openclaw.config.agent.model).toBe("azure/gpt-4.1");
expect(spec.inference.model).toBe("gpt-4.1");
expect(spec.inferenceRef.name).toBe("a-inference");
});

it("uses custom model when specified", () => {
const manifest = buildSandboxManifest("a", defaultOptions({ model: "o4-mini" }));
const spec = manifest.spec as any;
expect(spec.runtime.openclaw.config.agent.model).toBe("azure/o4-mini");
expect(spec.inference.model).toBe("o4-mini");
// S13: model is carried on the sibling InferencePolicy CR, not the
// sandbox spec. The runtime block still seeds OpenClaw config.
expect(spec.inferenceRef.name).toBe("a-inference");
});

it("sets standard isolation with RuntimeDefault seccomp", () => {
Expand All @@ -221,21 +216,23 @@ describe("ClawSandbox manifest generation", () => {
expect(spec.sandbox.seccompProfile).toBe("azureclaw-strict");
});

it("applies token budget values", () => {
it("references the InferencePolicy CR by name (token budget lives on the policy)", () => {
const manifest = buildSandboxManifest(
"a",
defaultOptions({ tokenBudgetDaily: "100000", tokenBudgetPerRequest: "4096" }),
);
const spec = manifest.spec as any;
expect(spec.inference.tokenBudget.daily).toBe(100000);
expect(spec.inference.tokenBudget.perRequest).toBe(4096);
// S13 phase2-config-authority-refs: budgets are carried on the
// sibling InferencePolicy CR, not inline on the sandbox spec.
expect(spec.inferenceRef.name).toBe("a-inference");
expect(spec.inference).toBeUndefined();
});

it("defaults token budgets to 0 (unlimited)", () => {
it("does not emit an inline inference block (S13 refs-only)", () => {
const manifest = buildSandboxManifest("a", defaultOptions());
const spec = manifest.spec as any;
expect(spec.inference.tokenBudget.daily).toBe(0);
expect(spec.inference.tokenBudget.perRequest).toBe(0);
expect(spec.inference).toBeUndefined();
expect(spec.inferenceRef).toEqual({ name: "a-inference" });
});

it("includes custom image when specified", () => {
Expand Down Expand Up @@ -269,15 +266,15 @@ describe("ClawSandbox manifest generation", () => {
expect(spec.networkPolicy.learnEgress).toBe(true);
});

it("adds governance config when enabled", () => {
it("adds governance config when enabled (S13: toolPolicyRef)", () => {
const manifest = buildSandboxManifest(
"a",
defaultOptions({ governance: true, trustThreshold: "750", policyProfile: "strict" }),
);
const spec = manifest.spec as any;
expect(spec.governance).toEqual({
enabled: true,
toolPolicy: "strict",
toolPolicyRef: { name: "a-toolpolicy" },
trustThreshold: 750,
});
});
Expand Down
53 changes: 41 additions & 12 deletions cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import chalk from "chalk";
import ora from "ora";
import { loadContext, resolveSecret } from "../config.js";
import { assertRuntimeWired, buildRuntimeBlock, flagToKind } from "../runtime.js";
import {
buildInferencePolicy,
buildToolPolicy,
inferenceRefName,
toolPolicyRefName,
} from "../refs.js";

export function addCommand(): Command {
const cmd = new Command("add");
Expand Down Expand Up @@ -72,15 +78,8 @@ export function addCommand(): Command {
allowPrivilegeEscalation: false,
writablePaths: ["/sandbox", "/tmp"],
},
inference: {
provider: "azure-ai-foundry",
model: options.model,
contentSafety: true,
promptShields: true,
tokenBudget: {
daily: parseInt(options.tokenBudgetDaily) || 0,
perRequest: parseInt(options.tokenBudgetPerRequest) || 0,
},
inferenceRef: {
name: inferenceRefName(name),
},
networkPolicy: {
defaultDeny: true,
Expand Down Expand Up @@ -113,7 +112,7 @@ export function addCommand(): Command {
if (options.governance) {
(sandbox.spec as Record<string, unknown>).governance = {
enabled: true,
toolPolicy: options.policyProfile || "default",
toolPolicyRef: { name: toolPolicyRefName(name) },
trustThreshold: parseInt(options.trustThreshold) || 500,
};
}
Expand Down Expand Up @@ -209,7 +208,29 @@ export function addCommand(): Command {
console.log(chalk.dim(` Skills: ${options.skills}`));
}

const yaml = JSON.stringify(sandbox, null, 2);
// S13: build companion same-namespace policy CRs (sibling to ClawSandbox).
const inferencePolicy = buildInferencePolicy({
sandboxName: name,
namespace: "azureclaw-system",
model: options.model,
provider: "azure-ai-foundry",
contentSafety: true,
promptShields: true,
tokenBudgetDaily: parseInt(options.tokenBudgetDaily) || 0,
tokenBudgetPerRequest: parseInt(options.tokenBudgetPerRequest) || 0,
});
const toolPolicy = options.governance
? buildToolPolicy({
sandboxName: name,
namespace: "azureclaw-system",
profile: options.policyProfile || "default",
})
: undefined;

const bundle: Record<string, unknown>[] = [inferencePolicy];
if (toolPolicy) bundle.push(toolPolicy);
bundle.push(sandbox);
const yaml = JSON.stringify(bundle, null, 2);

if (options.dryRun) {
console.log(chalk.bold("\nClawSandbox manifest (dry-run):\n"));
Expand Down Expand Up @@ -363,8 +384,16 @@ export function addCommand(): Command {
}
}
spinner.text = `Creating sandbox '${name}'...`;
// Apply InferencePolicy + (optional) ToolPolicy + ClawSandbox as a
// single multi-doc bundle. The controller resolves refs at reconcile
// time; if the policy CRs are missing the sandbox goes Degraded.
const bundleManifest = {
apiVersion: "v1",
kind: "List",
items: bundle,
};
await execa("kubectl", ["apply", "-f", "-"], {
input: JSON.stringify(sandbox),
input: JSON.stringify(bundleManifest),
stdio: ["pipe", "pipe", "pipe"],
});

Expand Down
24 changes: 22 additions & 2 deletions cli/src/commands/attest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ const POLICY_CR_KINDS: ReadonlyArray<{
plural: string;
refField: string;
}> = [
// S13: spec-level refs.
{ kind: "ToolPolicy", plural: "toolpolicies", refField: "toolPolicyRef" },
{
kind: "InferencePolicy",
plural: "inferencepolicies",
refField: "inferenceRef",
},
{
kind: "InferencePolicy",
plural: "inferencepolicies",
Expand Down Expand Up @@ -213,12 +219,26 @@ export function extractPolicyRefs(spec: unknown): Array<{
}
const gov = s.governance as Record<string, unknown> | undefined;
if (gov && typeof gov === "object") {
// S13: same-namespace `toolPolicyRef.name` shape.
const tpr = gov.toolPolicyRef as Record<string, unknown> | undefined;
if (tpr && typeof tpr === "object" && typeof tpr.name === "string") {
out.push({ kind: "ToolPolicy", name: tpr.name });
}
// Legacy `governance.toolPolicy.ref: string` shape — pre-S13.
const tp = gov.toolPolicy as Record<string, unknown> | undefined;
if (tp && typeof tp.ref === "string") {
if (tp && typeof tp === "object" && typeof tp.ref === "string") {
out.push({ kind: "ToolPolicy", name: tp.ref });
}
}
return out;
// De-duplicate (S13 may reference the same ToolPolicy via two paths
// during the rollout window).
const seen = new Set<string>();
return out.filter((r) => {
const k = `${r.kind}/${r.name}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}

async function buildReport(name: string, opts: { namespace: string }): Promise<AttestationReport> {
Expand Down
40 changes: 31 additions & 9 deletions cli/src/commands/up/sandbox_bringup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import chalk from "chalk";
import type { Stepper } from "../../stepper.js";
import { section, kvLine, checkLine } from "../../stepper.js";
import { saveContext } from "../../config.js";
import {
buildInferencePolicy,
buildToolPolicy,
inferenceRefName,
toolPolicyRefName,
} from "../../refs.js";

export interface SandboxBringUpContext {
options: {
Expand Down Expand Up @@ -325,12 +331,27 @@ export async function bringUpSandbox(ctx: SandboxBringUpContext): Promise<void>
}

stepper.update(`Creating sandbox '${options.name}'...`);
const sandboxNamespace = "azureclaw-system";
const inferencePolicy = buildInferencePolicy({
sandboxName: options.name,
namespace: sandboxNamespace,
model: options.model,
provider: "azure-openai",
endpoint: openAiEndpoint,
contentSafety: true,
promptShields: true,
});
const toolPolicy = buildToolPolicy({
sandboxName: options.name,
namespace: sandboxNamespace,
profile: "default",
});
const sandboxManifest = {
apiVersion: "azureclaw.azure.com/v1alpha1",
kind: "ClawSandbox",
metadata: {
name: options.name,
namespace: "azureclaw-system",
namespace: sandboxNamespace,
},
spec: {
runtime: {
Expand All @@ -342,12 +363,8 @@ export async function bringUpSandbox(ctx: SandboxBringUpContext): Promise<void>
sandbox: {
isolation: options.isolation,
},
inference: {
provider: "azure-openai",
model: options.model,
endpoint: openAiEndpoint,
contentSafety: true,
promptShields: true,
inferenceRef: {
name: inferenceRefName(options.name),
},
networkPolicy: {
defaultDeny: true,
Expand All @@ -356,13 +373,18 @@ export async function bringUpSandbox(ctx: SandboxBringUpContext): Promise<void>
},
governance: {
enabled: true,
toolPolicy: "default",
toolPolicyRef: { name: toolPolicyRefName(options.name) },
trustThreshold: 500,
},
},
};
const bundleManifest = {
apiVersion: "v1",
kind: "List",
items: [inferencePolicy, toolPolicy, sandboxManifest],
};
await execa("kubectl", ["apply", "-f", "-"], {
input: JSON.stringify(sandboxManifest),
input: JSON.stringify(bundleManifest),
stdio: ["pipe", "pipe", "pipe"],
});

Expand Down
Loading
Loading