Skip to content

Commit fa69d76

Browse files
committed
fix: add AWS tag character restrictions and improve error messages
- Add Unicode character validation to TagKeySchema and TagValueSchema per AWS tag restrictions (letters, digits, whitespace, _ . : / = + - @) - Improve removeTag error when key is inherited from project defaults: now hints to use "tag remove-defaults" instead - Add tests for character restrictions and updated error messages
1 parent 235c17b commit fa69d76

File tree

4 files changed

+72
-7
lines changed

4 files changed

+72
-7
lines changed

src/cli/commands/tag/__tests__/action.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ describe('addTag', () => {
134134
it('rejects tag value exceeding 256 chars', async () => {
135135
await expect(addTag('agent:myAgent', 'key', 'v'.repeat(257))).rejects.toThrow('Invalid tag value');
136136
});
137+
138+
it('rejects tag key with invalid characters', async () => {
139+
await expect(addTag('agent:myAgent', 'key\x00bad', 'value')).rejects.toThrow('Invalid tag key');
140+
});
141+
142+
it('rejects tag value with invalid characters', async () => {
143+
await expect(addTag('agent:myAgent', 'key', 'value\x00bad')).rejects.toThrow('Invalid tag value');
144+
});
137145
});
138146

139147
describe('removeTag', () => {
@@ -148,8 +156,8 @@ describe('removeTag', () => {
148156
expect(written.agents[0].tags).toEqual({ team: 'a' });
149157
});
150158

151-
it('throws when key not found', async () => {
152-
await expect(removeTag('agent:myAgent', 'nonexistent')).rejects.toThrow('Tag key');
159+
it('throws when key not found with hint about defaults', async () => {
160+
await expect(removeTag('agent:myAgent', 'nonexistent')).rejects.toThrow('remove-defaults');
153161
});
154162
});
155163

src/cli/commands/tag/action.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export async function removeTag(resourceRefStr: string, key: string): Promise<{
127127
throw new Error(`${ref.type} "${ref.name}" not found in project.`);
128128
}
129129
if (!resource.tags || !(key in resource.tags)) {
130-
throw new Error(`Tag key "${key}" not found on ${ref.type} "${ref.name}".`);
130+
throw new Error(
131+
`Tag key "${key}" not found on ${ref.type} "${ref.name}". ` +
132+
`If this is an inherited project default, use "tag remove-defaults --key ${key}" instead.`
133+
);
131134
}
132135
delete resource.tags[key];
133136
if (Object.keys(resource.tags).length === 0) {
@@ -141,7 +144,10 @@ export async function removeTag(resourceRefStr: string, key: string): Promise<{
141144
throw new Error(`gateway "${ref.name}" not found in MCP spec.`);
142145
}
143146
if (!gateway.tags || !(key in gateway.tags)) {
144-
throw new Error(`Tag key "${key}" not found on gateway "${ref.name}".`);
147+
throw new Error(
148+
`Tag key "${key}" not found on gateway "${ref.name}". ` +
149+
`If this is an inherited project default, use "tag remove-defaults --key ${key}" instead.`
150+
);
145151
}
146152
delete gateway.tags[key];
147153
if (Object.keys(gateway.tags).length === 0) {

src/schema/schemas/__tests__/tags.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('TagKeySchema', () => {
77
expect(TagKeySchema.parse('environment')).toBe('environment');
88
expect(TagKeySchema.parse('agentcore:created-by')).toBe('agentcore:created-by');
99
expect(TagKeySchema.parse('a'.repeat(128))).toHaveLength(128);
10+
expect(TagKeySchema.parse('cost-center_v2')).toBe('cost-center_v2');
1011
});
1112

1213
it('rejects empty string', () => {
@@ -16,6 +17,20 @@ describe('TagKeySchema', () => {
1617
it('rejects keys longer than 128 characters', () => {
1718
expect(() => TagKeySchema.parse('a'.repeat(129))).toThrow();
1819
});
20+
21+
it('rejects whitespace-only keys', () => {
22+
expect(() => TagKeySchema.parse(' ')).toThrow();
23+
expect(() => TagKeySchema.parse(' \t ')).toThrow();
24+
});
25+
26+
it('rejects aws: prefixed keys', () => {
27+
expect(() => TagKeySchema.parse('aws:internal')).toThrow();
28+
});
29+
30+
it('rejects keys with invalid characters', () => {
31+
expect(() => TagKeySchema.parse('key\x00null')).toThrow();
32+
expect(() => TagKeySchema.parse('key{bracket}')).toThrow();
33+
});
1934
});
2035

2136
describe('TagValueSchema', () => {
@@ -28,6 +43,10 @@ describe('TagValueSchema', () => {
2843
it('rejects values longer than 256 characters', () => {
2944
expect(() => TagValueSchema.parse('a'.repeat(257))).toThrow();
3045
});
46+
47+
it('rejects values with invalid characters', () => {
48+
expect(() => TagValueSchema.parse('val\x00ue')).toThrow();
49+
});
3150
});
3251

3352
describe('TagsSchema', () => {
@@ -43,6 +62,18 @@ describe('TagsSchema', () => {
4362
it('accepts empty object', () => {
4463
expect(TagsSchema.parse({})).toEqual({});
4564
});
65+
66+
it('rejects more than 50 tags', () => {
67+
const tags: Record<string, string> = {};
68+
for (let i = 0; i < 51; i++) tags[`key${i}`] = `value${i}`;
69+
expect(() => TagsSchema.parse(tags)).toThrow();
70+
});
71+
72+
it('accepts 50 tags', () => {
73+
const tags: Record<string, string> = {};
74+
for (let i = 0; i < 50; i++) tags[`key${i}`] = `value${i}`;
75+
expect(TagsSchema.parse(tags)).toEqual(tags);
76+
});
4677
});
4778

4879
describe('AgentCoreProjectSpecSchema with tags', () => {
Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { z } from 'zod';
22

3-
export const TagKeySchema = z.string().min(1).max(128);
4-
export const TagValueSchema = z.string().max(256);
5-
export const TagsSchema = z.record(TagKeySchema, TagValueSchema).optional();
3+
const TAG_CHAR_PATTERN = /^[\p{L}\p{N}\s_.:/=+\-@]+$/u;
4+
const TAG_CHAR_MESSAGE = 'can only contain Unicode letters, digits, whitespace, and _ . : / = + - @';
5+
6+
export const TagKeySchema = z
7+
.string()
8+
.min(1)
9+
.max(128)
10+
.regex(/\S/, 'Tag key must contain at least one non-whitespace character')
11+
.regex(TAG_CHAR_PATTERN, `Tag key ${TAG_CHAR_MESSAGE}`)
12+
.refine(key => !key.startsWith('aws:'), 'Tag keys starting with "aws:" are reserved');
13+
14+
const TAG_VALUE_CHAR_PATTERN = /^[\p{L}\p{N}\s_.:/=+\-@]*$/u;
15+
16+
export const TagValueSchema = z
17+
.string()
18+
.max(256)
19+
.regex(TAG_VALUE_CHAR_PATTERN, `Tag value ${TAG_CHAR_MESSAGE}`);
20+
21+
export const TagsSchema = z
22+
.record(TagKeySchema, TagValueSchema)
23+
.refine(tags => Object.keys(tags).length <= 50, 'A resource can have at most 50 tags')
24+
.optional();
25+
626
export type Tags = z.infer<typeof TagsSchema>;

0 commit comments

Comments
 (0)