diff --git a/.changeset/fix-redteam-targets-get-nested-output-flag.md b/.changeset/fix-redteam-targets-get-nested-output-flag.md new file mode 100644 index 0000000..c547c48 --- /dev/null +++ b/.changeset/fix-redteam-targets-get-nested-output-flag.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": patch +--- + +Fix `airs redteam targets get` so nested `connection_params` (`request_headers`, `request_json`, `response_json`) render as indented JSON instead of `[object Object]`, and add `--output ` for parity with `targets list` / `scan`. diff --git a/docs/cli/examples/redteam.yaml b/docs/cli/examples/redteam.yaml index ea45cf4..ac662e5 100644 --- a/docs/cli/examples/redteam.yaml +++ b/docs/cli/examples/redteam.yaml @@ -276,7 +276,7 @@ "redteam targets get": examples: - - note: Show full target detail (connection, background, metadata) + - note: Show full target detail (nested connection_params expand inline as indented JSON) input: airs redteam targets get 00000000-0000-0000-0000-000000000001 output: | Prisma AIRS — AI Red Team @@ -292,9 +292,28 @@ Connection: api_endpoint: https://api.example.com/v1/chat/completions - request_headers: [object Object] - request_json: [object Object] - response_json: [object Object] + request_headers: { + "Content-Type": "application/json", + "apikey": "" + } + request_json: { + "model": "", + "messages": [ + { + "role": "user", + "content": "{INPUT}" + } + ] + } + response_json: { + "choices": [ + { + "message": { + "content": "" + } + } + ] + } response_key: content target_connection_config: null curl: curl \ @@ -318,6 +337,35 @@ content_filter_error_code: 403 probe_message: I like turtles request_timeout: 110 + - note: Emit full target as JSON for piping to jq + input: airs redteam targets get 00000000-0000-0000-0000-000000000001 --output json + output: | + { + "uuid": "00000000-0000-0000-0000-000000000001", + "name": "example-target", + "status": "ACTIVE", + "targetType": "APPLICATION", + "active": true, + "connectionParams": { + "api_endpoint": "https://api.example.com/v1/chat/completions", + "request_headers": { + "Content-Type": "application/json", + "apikey": "" + }, + "request_json": { + "model": "", + "messages": [ + { "role": "user", "content": "{INPUT}" } + ] + }, + "response_json": { + "choices": [ + { "message": { "content": "" } } + ] + }, + "response_key": "content" + } + } "redteam targets profile": examples: diff --git a/docs/cli/redteam/targets.md b/docs/cli/redteam/targets.md index cdf3a5e..b6c259c 100644 --- a/docs/cli/redteam/targets.md +++ b/docs/cli/redteam/targets.md @@ -47,9 +47,15 @@ airs redteam targets get [options] - `uuid` (required) — +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--output ` | No | `pretty` | Output format: pretty, json, yaml | + #### Examples -*Show full target detail (connection, background, metadata)* +*Show full target detail (nested connection_params expand inline as indented JSON)* ```bash airs redteam targets get 00000000-0000-0000-0000-000000000001 @@ -69,9 +75,28 @@ Target Detail: Connection: api_endpoint: https://api.example.com/v1/chat/completions - request_headers: [object Object] - request_json: [object Object] - response_json: [object Object] + request_headers: { + "Content-Type": "application/json", + "apikey": "" + } + request_json: { + "model": "", + "messages": [ + { + "role": "user", + "content": "{INPUT}" + } + ] + } + response_json: { + "choices": [ + { + "message": { + "content": "" + } + } + ] + } response_key: content target_connection_config: null curl: curl \ @@ -97,6 +122,41 @@ Target Detail: request_timeout: 110 ``` +*Emit full target as JSON for piping to jq* + +```bash +airs redteam targets get 00000000-0000-0000-0000-000000000001 --output json +``` + +```text +{ + "uuid": "00000000-0000-0000-0000-000000000001", + "name": "example-target", + "status": "ACTIVE", + "targetType": "APPLICATION", + "active": true, + "connectionParams": { + "api_endpoint": "https://api.example.com/v1/chat/completions", + "request_headers": { + "Content-Type": "application/json", + "apikey": "" + }, + "request_json": { + "model": "", + "messages": [ + { "role": "user", "content": "{INPUT}" } + ] + }, + "response_json": { + "choices": [ + { "message": { "content": "" } } + ] + }, + "response_key": "content" + } +} +``` + --- ### redteam targets create diff --git a/src/cli/commands/redteam.ts b/src/cli/commands/redteam.ts index a44ea4e..89acf34 100644 --- a/src/cli/commands/redteam.ts +++ b/src/cli/commands/redteam.ts @@ -859,12 +859,14 @@ export function registerRedteamCommand(program: Command): void { targets .command('get ') .description('Get target details') - .action(async (uuid: string) => { + .option('--output ', 'Output format: pretty, json, yaml', 'pretty') + .action(async (uuid: string, opts) => { try { - renderRedteamHeader(); + const fmt = opts.output as OutputFormat; + if (fmt === 'pretty') renderRedteamHeader(); const service = await createService(); const target = await service.getTarget(uuid); - renderTargetDetail(target); + renderTargetDetail(target, fmt); } catch (err) { renderError(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/cli/renderer/redteam.ts b/src/cli/renderer/redteam.ts index 5c940bb..4b68baf 100644 --- a/src/cli/renderer/redteam.ts +++ b/src/cli/renderer/redteam.ts @@ -361,17 +361,37 @@ export function renderPromptSetList( console.log(); } +/** Format a scalar/object value for pretty rendering — nested objects become indented JSON. */ +function formatDetailValue(value: unknown, indent: string): string { + if (value === null || value === undefined) return String(value); + if (typeof value !== 'object') return String(value); + const json = JSON.stringify(value, null, 2); + // Indent every line after the first so the block lines up under the key. + return json.split('\n').join(`\n${indent}`); +} + /** Render target detail. */ -export function renderTargetDetail(target: { - uuid: string; - name: string; - status: string; - targetType?: string; - active: boolean; - connectionParams?: Record; - background?: Record; - metadata?: Record; -}): void { +export function renderTargetDetail( + target: { + uuid: string; + name: string; + status: string; + targetType?: string; + active: boolean; + connectionParams?: Record; + background?: Record; + metadata?: Record; + }, + format: OutputFormat = 'pretty', +): void { + if (format !== 'pretty') { + if (format === 'json') { + console.log(JSON.stringify(target, null, 2)); + } else if (format === 'yaml') { + console.log(yamlDump(target)); + } + return; + } console.log(chalk.bold('\n Target Detail:\n')); console.log(` UUID: ${chalk.dim(target.uuid)}`); console.log(` Name: ${target.name}`); @@ -382,19 +402,19 @@ export function renderTargetDetail(target: { if (target.connectionParams) { console.log(chalk.bold('\n Connection:')); for (const [k, v] of Object.entries(target.connectionParams)) { - console.log(` ${k}: ${chalk.dim(String(v))}`); + console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); } } if (target.background) { console.log(chalk.bold('\n Background:')); for (const [k, v] of Object.entries(target.background)) { - if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); + if (v != null) console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); } } if (target.metadata) { console.log(chalk.bold('\n Metadata:')); for (const [k, v] of Object.entries(target.metadata)) { - if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); + if (v != null) console.log(` ${k}: ${chalk.dim(formatDetailValue(v, ' '))}`); } } console.log(); diff --git a/tests/unit/cli/redteam-output-flag.spec.ts b/tests/unit/cli/redteam-output-flag.spec.ts index f6507e9..818f3d8 100644 --- a/tests/unit/cli/redteam-output-flag.spec.ts +++ b/tests/unit/cli/redteam-output-flag.spec.ts @@ -34,6 +34,26 @@ const sampleValues = [ { name: 'persona', value: 'doctor' }, ]; +const sampleTarget = { + uuid: 'tg-uuid-001', + name: 'litellm-mistral-7b', + status: 'INACTIVE', + targetType: 'APPLICATION', + active: false, + connectionParams: { + api_endpoint: 'http://litellm.example.local:4000/v1/chat/completions', + request_headers: { 'Content-Type': 'application/json', apikey: 'sk-xxx' }, + request_json: { + model: 'mistral-7b', + messages: [{ role: 'user', content: '{INPUT}' }], + }, + response_json: { choices: [{ message: { content: '' } }] }, + response_key: 'choices.0.message.content', + }, + background: { industry: 'Generic', use_case: 'Chatbot' }, + metadata: { rate_limit: 50, multi_turn: false }, +}; + describe('renderPromptSetDetail --output', () => { it('emits JSON with combined detail + versionInfo when format=json', async () => { const { renderPromptSetDetail } = await import('../../../src/cli/renderer/redteam.js'); @@ -93,3 +113,62 @@ describe('renderPropertyValues --output', () => { expect(output.join('\n')).toContain('No property values'); }); }); + +describe('renderTargetDetail --output', () => { + it('emits JSON with full target payload when format=json', async () => { + const { renderTargetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderTargetDetail(sampleTarget, 'json'); + const parsed = JSON.parse(output.join('\n')); + expect(parsed.uuid).toBe('tg-uuid-001'); + expect(parsed.name).toBe('litellm-mistral-7b'); + expect(parsed.connectionParams.request_headers).toEqual({ + 'Content-Type': 'application/json', + apikey: 'sk-xxx', + }); + expect(parsed.connectionParams.request_json.model).toBe('mistral-7b'); + }); + + it('emits YAML with full target payload when format=yaml', async () => { + const { renderTargetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderTargetDetail(sampleTarget, 'yaml'); + const parsed = yamlLoad(output.join('\n')) as Record; + expect(parsed.uuid).toBe('tg-uuid-001'); + const conn = parsed.connectionParams as Record; + expect((conn.request_headers as Record).apikey).toBe('sk-xxx'); + }); + + it('renders nested connection_params as indented JSON in pretty mode (not [object Object])', async () => { + const { renderTargetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderTargetDetail(sampleTarget, 'pretty'); + const text = output.join('\n'); + expect(text).not.toContain('[object Object]'); + expect(text).toContain('request_headers'); + expect(text).toContain('apikey'); + expect(text).toContain('sk-xxx'); + expect(text).toContain('mistral-7b'); + }); + + it('defaults to pretty when no format is passed', async () => { + const { renderTargetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderTargetDetail(sampleTarget); + const text = output.join('\n'); + expect(text).toContain('Target Detail'); + expect(text).not.toContain('[object Object]'); + }); + + it('renders nested background/metadata objects without [object Object]', async () => { + const { renderTargetDetail } = await import('../../../src/cli/renderer/redteam.js'); + renderTargetDetail( + { + ...sampleTarget, + background: { industry: 'Generic', nested: { foo: 'bar' } }, + metadata: { tags: { a: 1, b: 2 } }, + }, + 'pretty', + ); + const text = output.join('\n'); + expect(text).not.toContain('[object Object]'); + expect(text).toContain('foo'); + expect(text).toContain('bar'); + }); +});