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
5 changes: 5 additions & 0 deletions .changeset/fix-redteam-targets-get-nested-output-flag.md
Original file line number Diff line number Diff line change
@@ -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 <pretty|json|yaml>` for parity with `targets list` / `scan`.
56 changes: 52 additions & 4 deletions docs/cli/examples/redteam.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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": "<api-key>"
}
request_json: {
"model": "<model-id>",
"messages": [
{
"role": "user",
"content": "{INPUT}"
}
]
}
response_json: {
"choices": [
{
"message": {
"content": ""
}
}
]
}
response_key: content
target_connection_config: null
curl: curl \
Expand All @@ -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": "<api-key>"
},
"request_json": {
"model": "<model-id>",
"messages": [
{ "role": "user", "content": "{INPUT}" }
]
},
"response_json": {
"choices": [
{ "message": { "content": "" } }
]
},
"response_key": "content"
}
}

"redteam targets profile":
examples:
Expand Down
68 changes: 64 additions & 4 deletions docs/cli/redteam/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ airs redteam targets get [options] <uuid>

- `uuid` (required) —

#### Options

| Flag | Required | Default | Description |
|------|:--------:|---------|-------------|
| `--output <format>` | 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
Expand All @@ -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": "<api-key>"
}
request_json: {
"model": "<model-id>",
"messages": [
{
"role": "user",
"content": "{INPUT}"
}
]
}
response_json: {
"choices": [
{
"message": {
"content": ""
}
}
]
}
response_key: content
target_connection_config: null
curl: curl \
Expand All @@ -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": "<api-key>"
},
"request_json": {
"model": "<model-id>",
"messages": [
{ "role": "user", "content": "{INPUT}" }
]
},
"response_json": {
"choices": [
{ "message": { "content": "" } }
]
},
"response_key": "content"
}
}
```

---

### redteam targets create
Expand Down
8 changes: 5 additions & 3 deletions src/cli/commands/redteam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,12 +859,14 @@ export function registerRedteamCommand(program: Command): void {
targets
.command('get <uuid>')
.description('Get target details')
.action(async (uuid: string) => {
.option('--output <format>', '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);
Expand Down
46 changes: 33 additions & 13 deletions src/cli/renderer/redteam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
background?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}): void {
export function renderTargetDetail(
target: {
uuid: string;
name: string;
status: string;
targetType?: string;
active: boolean;
connectionParams?: Record<string, unknown>;
background?: Record<string, unknown>;
metadata?: Record<string, unknown>;
},
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}`);
Expand All @@ -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();
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/cli/redteam-output-flag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<string, unknown>;
expect(parsed.uuid).toBe('tg-uuid-001');
const conn = parsed.connectionParams as Record<string, unknown>;
expect((conn.request_headers as Record<string, string>).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');
});
});
Loading