Skip to content
Open
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
3 changes: 3 additions & 0 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def build_config(
channel_metadata: dict[str, str] | None = None,
trace: bool = False,
user_id: str = "",
tool_profile: str = "",
) -> TaskConfig:
"""Build and validate configuration from explicit parameters.

Expand Down Expand Up @@ -146,6 +147,7 @@ def build_config(
channel_metadata=channel_metadata or {},
trace=trace,
user_id=user_id,
tool_profile=tool_profile,
)


Expand All @@ -170,6 +172,7 @@ def get_config() -> TaskConfig:
# an unreachable ``traces//`` key.
trace=os.environ.get("TRACE", "").lower() in ("1", "true", "yes"),
user_id=os.environ.get("USER_ID", ""),
tool_profile=os.environ.get("TOOL_PROFILE", ""),
)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
Expand Down
3 changes: 3 additions & 0 deletions agent/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ class TaskConfig(BaseModel):
# previews live, so dropping ``trace`` here silently no-ops the
# feature for the fields that matter.
trace: bool = False
# Tool profile selected at task submission (from Blueprint.toolProfiles).
# Empty string means legacy single-tier behavior (no profile selected).
tool_profile: str = ""
# Enriched mid-flight by pipeline.py:
cedar_policies: list[str] = []
issue: GitHubIssue | None = None
Expand Down
19 changes: 19 additions & 0 deletions agent/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,22 @@ def test_auto_generated_task_id(self):
)
assert config.task_id
assert len(config.task_id) == 12

def test_tool_profile_defaults_to_empty(self):
config = build_config(
repo_url="owner/repo",
task_description="fix bug",
github_token="ghp_test",
aws_region="us-east-1",
)
assert config.tool_profile == ""

def test_tool_profile_passed_through(self):
config = build_config(
repo_url="owner/repo",
task_description="fix bug",
github_token="ghp_test",
aws_region="us-east-1",
tool_profile="frontend",
)
assert config.tool_profile == "frontend"
17 changes: 17 additions & 0 deletions agent/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,23 @@ def test_trace_false_allows_empty_user_id(self):
assert config.trace is False
assert config.user_id == ""

def test_tool_profile_defaults_to_empty_string(self):
config = TaskConfig(
repo_url="owner/repo",
github_token="ghp_test",
aws_region="us-east-1",
)
assert config.tool_profile == ""

def test_tool_profile_accepts_valid_name(self):
config = TaskConfig(
repo_url="owner/repo",
github_token="ghp_test",
aws_region="us-east-1",
tool_profile="frontend",
)
assert config.tool_profile == "frontend"


class TestRepoSetup:
def test_construction(self):
Expand Down
88 changes: 88 additions & 0 deletions cdk/src/constructs/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,48 @@ import { Construct, IValidation } from 'constructs';

const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
const DOMAIN_PATTERN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
const TOOL_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;

/**
* A named tool profile that defines which tools, MCP servers, skills,
* and Cedar policies are available to the agent for a given task scope.
*
* Profiles are deploy-time artifacts — defined in CDK source, deployed
* via CloudFormation, and stored in DynamoDB. At task submission time,
* only the profile *name* is user-controlled; the definition itself
* is trusted (same trust level as Blueprint.security.cedarPolicies).
*/
export interface ToolProfile {
/**
* Tool capability tier for this profile.
* @default 'default'
*/
readonly capabilityTier?: 'default' | 'extended';

/**
* MCP server identifiers activated for this profile.
* These must correspond to MCP servers registered with the platform.
*/
readonly mcpServers?: readonly string[];

/**
* Skill identifiers activated for this profile.
* References deploy-time skill definitions (SKILL.md directories)
* bundled into the agent runtime image or fetched at session start.
*/
readonly skills?: readonly string[];

/**
* Additional Cedar policy statements for this profile.
* Appended to baseline deny-list policies during evaluation.
*/
readonly cedarPolicies?: readonly string[];

/**
* Human-readable description of the profile's purpose.
*/
readonly description?: string;
}

/**
* Properties for the Blueprint construct.
Expand Down Expand Up @@ -119,6 +161,17 @@ export interface BlueprintProps {
*/
readonly egressAllowlist?: string[];
};

/**
* Named tool profiles defining per-task tool configurations.
* Keys are profile names (lowercase alphanumeric + hyphens, 2-64 chars).
* At task submission, the user selects a profile by name; the platform
* activates only the tools/skills/policies defined in that profile.
*
* If omitted, the repo uses legacy single-tier behavior based on
* security.cedarPolicies alone.
*/
readonly toolProfiles?: Readonly<Record<string, ToolProfile>>;
}

/**
Expand All @@ -145,15 +198,22 @@ export class Blueprint extends Construct {
*/
public readonly cedarPolicies: readonly string[];

/**
* Tool profiles from the toolProfiles prop, exposed for inspection.
*/
public readonly toolProfiles: Readonly<Record<string, ToolProfile>>;

constructor(scope: Construct, id: string, props: BlueprintProps) {
super(scope, id);

this.egressAllowlist = [...(props.networking?.egressAllowlist ?? [])];
this.cedarPolicies = [...(props.security?.cedarPolicies ?? [])];
this.toolProfiles = props.toolProfiles ?? {};

// Validate repo format at construct time
this.node.addValidation(new RepoFormatValidation(props.repo));
this.node.addValidation(new DomainFormatValidation(this.egressAllowlist));
this.node.addValidation(new ToolProfileNameValidation(this.toolProfiles));

const now = new Date().toISOString();

Expand Down Expand Up @@ -192,6 +252,9 @@ export class Blueprint extends Construct {
if (this.cedarPolicies.length > 0) {
item.cedar_policies = { L: this.cedarPolicies.map(p => ({ S: p })) };
}
if (Object.keys(this.toolProfiles).length > 0) {
item.tool_profiles = { S: JSON.stringify(this.toolProfiles) };
}

new cr.AwsCustomResource(this, 'RepoConfigCR', {
timeout: Duration.minutes(5),
Expand Down Expand Up @@ -263,6 +326,7 @@ export class Blueprint extends Construct {
if (props.pipeline?.pollIntervalMs !== undefined) fields.push(', #poll_interval_ms = :poll_interval_ms');
if (this.egressAllowlist.length > 0) fields.push(', #egress_allowlist = :egress_allowlist');
if (this.cedarPolicies.length > 0) fields.push(', #cedar_policies = :cedar_policies');
if (Object.keys(this.toolProfiles).length > 0) fields.push(', #tool_profiles = :tool_profiles');
return fields.join('');
}

Expand All @@ -277,6 +341,7 @@ export class Blueprint extends Construct {
if (props.pipeline?.pollIntervalMs !== undefined) names['#poll_interval_ms'] = 'poll_interval_ms';
if (this.egressAllowlist.length > 0) names['#egress_allowlist'] = 'egress_allowlist';
if (this.cedarPolicies.length > 0) names['#cedar_policies'] = 'cedar_policies';
if (Object.keys(this.toolProfiles).length > 0) names['#tool_profiles'] = 'tool_profiles';
return names;
}

Expand All @@ -291,6 +356,7 @@ export class Blueprint extends Construct {
if (props.pipeline?.pollIntervalMs !== undefined) values[':poll_interval_ms'] = { N: String(props.pipeline.pollIntervalMs) };
if (this.egressAllowlist.length > 0) values[':egress_allowlist'] = { L: this.egressAllowlist.map(d => ({ S: d })) };
if (this.cedarPolicies.length > 0) values[':cedar_policies'] = { L: this.cedarPolicies.map(p => ({ S: p })) };
if (Object.keys(this.toolProfiles).length > 0) values[':tool_profiles'] = { S: JSON.stringify(this.toolProfiles) };
return values;
}
}
Expand Down Expand Up @@ -325,3 +391,25 @@ class DomainFormatValidation implements IValidation {
return errors;
}
}

/**
* Validates tool profile names (lowercase alphanumeric + hyphens, 2-64 chars).
* Single-char profile names are allowed if they match [a-z0-9].
*/
class ToolProfileNameValidation implements IValidation {
constructor(private readonly profiles: Readonly<Record<string, ToolProfile>>) {}

public validate(): string[] {
const errors: string[] = [];
for (const name of Object.keys(this.profiles)) {
if (name.length === 1) {
if (!/^[a-z0-9]$/.test(name)) {
errors.push(`Invalid tool profile name: '${name}'. Expected lowercase alphanumeric and hyphens (2-64 chars).`);
}
} else if (!TOOL_PROFILE_NAME_PATTERN.test(name)) {
errors.push(`Invalid tool profile name: '${name}'. Expected lowercase alphanumeric and hyphens (2-64 chars).`);
}
}
return errors;
}
}
22 changes: 20 additions & 2 deletions cdk/src/handlers/shared/create-task-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ import type { APIGatewayProxyResult } from 'aws-lambda';
import { ulid } from 'ulid';
import { generateBranchName } from './gateway';
import { logger } from './logger';
import { checkRepoOnboarded } from './repo-config';
import { checkRepoOnboarded, loadRepoConfig, parseToolProfiles, isValidToolProfile } from './repo-config';
import { ErrorCode, errorResponse, successResponse } from './response';
import { type ChannelSource, type CreateTaskRequest, isPrTaskType, type TaskRecord, type TaskType, toTaskDetail } from './types';
import { computeTtlEpoch, DEFAULT_MAX_TURNS, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, isValidTaskType, MAX_TASK_DESCRIPTION_LENGTH, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation';
import { computeTtlEpoch, DEFAULT_MAX_TURNS, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, isValidTaskType, MAX_TASK_DESCRIPTION_LENGTH, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber, validateToolProfile } from './validation';
import { TaskStatus } from '../../constructs/task-status';

/**
Expand Down Expand Up @@ -132,6 +132,23 @@ export async function createTaskCore(
}
const userTrace = body.trace === true;

// Validate tool_profile format (if provided)
const toolProfileResult = validateToolProfile(body.tool_profile);
if (toolProfileResult === null) {
return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Invalid tool_profile. Must be a lowercase alphanumeric string with hyphens (1-64 chars).', requestId);
}

// If a tool_profile is specified, validate it exists in the repo's Blueprint
if (toolProfileResult !== undefined) {
const repoConfig = await loadRepoConfig(body.repo);
if (repoConfig) {
const profiles = parseToolProfiles(repoConfig.tool_profiles);
if (!isValidToolProfile(toolProfileResult, profiles)) {
return errorResponse(422, ErrorCode.VALIDATION_ERROR, 'Invalid tool_profile. The specified profile does not exist for this repository.', requestId);
}
}
}

// 2. Screen task description with Bedrock Guardrail (fail-closed: unscreened content
// must not reach the agent — a Bedrock outage blocks task submissions)
if (bedrockClient && body.task_description) {
Expand Down Expand Up @@ -233,6 +250,7 @@ export async function createTaskCore(
...(userMaxTurns !== undefined && { max_turns: userMaxTurns }),
...(userMaxBudgetUsd !== undefined && { max_budget_usd: userMaxBudgetUsd }),
...(userTrace && { trace: true }),
...(toolProfileResult !== undefined && { tool_profile: toolProfileResult }),
...(context.idempotencyKey && { idempotency_key: context.idempotencyKey }),
channel_source: context.channelSource,
channel_metadata: context.channelMetadata,
Expand Down
44 changes: 44 additions & 0 deletions cdk/src/handlers/shared/repo-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ import { logger } from './logger';
*/
export type ComputeType = 'agentcore' | 'ecs';

/** Runtime representation of a tool profile stored in RepoConfig. */
export interface StoredToolProfile {
readonly capabilityTier?: 'default' | 'extended';
readonly mcpServers?: readonly string[];
readonly skills?: readonly string[];
readonly cedarPolicies?: readonly string[];
readonly description?: string;
}

export interface RepoConfig {
readonly repo: string;
readonly status: 'active' | 'removed';
Expand All @@ -42,6 +51,8 @@ export interface RepoConfig {
readonly poll_interval_ms?: number;
readonly egress_allowlist?: string[];
readonly cedar_policies?: string[];
/** JSON-serialized map of profile name → ToolProfile, written by Blueprint. */
readonly tool_profiles?: string;
}

/**
Expand All @@ -59,6 +70,8 @@ export interface BlueprintConfig {
readonly poll_interval_ms?: number;
readonly egress_allowlist?: string[];
readonly cedar_policies?: string[];
/** Parsed tool profiles map (profile name → definition). */
readonly tool_profiles?: Readonly<Record<string, StoredToolProfile>>;
}

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
Expand Down Expand Up @@ -138,3 +151,34 @@ export async function loadRepoConfig(repo: string): Promise<RepoConfig | null> {
throw new Error(`Unable to load repo config for '${repo}': ${String(err)}`);
}
}

/**
* Parse the tool_profiles JSON string from a RepoConfig into a typed map.
* Returns an empty object if the field is absent or unparseable.
*/
export function parseToolProfiles(raw: string | undefined): Readonly<Record<string, StoredToolProfile>> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
logger.warn('tool_profiles is not a valid object, ignoring', { raw_type: typeof parsed });
return {};
}
return parsed as Record<string, StoredToolProfile>;
} catch (err) {
logger.warn('Failed to parse tool_profiles JSON', {
error: err instanceof Error ? err.message : String(err),
});
return {};
}
}

/**
* Validate that a tool profile name exists in the given profiles map.
* @param profileName - the profile name from the task request.
* @param profiles - the parsed tool profiles from RepoConfig.
* @returns true if the profile exists.
*/
export function isValidToolProfile(profileName: string, profiles: Readonly<Record<string, StoredToolProfile>>): boolean {
return Object.prototype.hasOwnProperty.call(profiles, profileName);
}
11 changes: 11 additions & 0 deletions cdk/src/handlers/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface TaskRecord {
readonly memory_written?: boolean;
readonly compute_type?: ComputeType;
readonly compute_metadata?: Record<string, string>;
/** Tool profile name selected at task submission (from Blueprint.toolProfiles). */
readonly tool_profile?: string;
readonly ttl?: number;
/**
* Optional per-task override for the FanOutConsumer's channel filters
Expand Down Expand Up @@ -198,6 +200,8 @@ export interface TaskDetail {
* the field being present; CLI download resolves this via the
* ``get-trace-url`` handler rather than hitting S3 directly. */
readonly trace_s3_uri: string | null;
/** Tool profile selected at submission, or ``null`` for legacy single-tier tasks. */
readonly tool_profile: string | null;
}

/**
Expand Down Expand Up @@ -275,6 +279,12 @@ export interface CreateTaskRequest {
readonly attachments?: Attachment[];
/** Enable 4 KB debug previews (design §10.1, opt-in per task). */
readonly trace?: boolean;
/**
* Named tool profile to activate for this task. Must reference a profile
* defined in the repo's Blueprint.toolProfiles. When omitted, the repo's
* legacy single-tier behavior applies.
*/
readonly tool_profile?: string;
}

/**
Expand Down Expand Up @@ -333,6 +343,7 @@ export function toTaskDetail(record: TaskRecord): TaskDetail {
prompt_version: record.prompt_version ?? null,
trace: record.trace === true,
trace_s3_uri: record.trace_s3_uri ?? null,
tool_profile: record.tool_profile ?? null,
};
}

Expand Down
Loading
Loading