mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
fix: block credential-bearing harness arguments
This commit is contained in:
parent
3c44c001d3
commit
19f57475fd
10 changed files with 267 additions and 12 deletions
|
|
@ -172,6 +172,8 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
|
|||
- **Path traversal.** `validatePathSegment()` + `ensureWithinBase()` on every user-supplied path.
|
||||
- **Env passthrough.** Agents receive only the keys in the configured safe allowlist; see
|
||||
`server/src/utils/codex-env.ts` and `server/src/utils/hermes-env.ts`.
|
||||
- **Launch arguments.** Never put credential values in provider commands or arguments; use an
|
||||
allowlisted environment key or run-scoped brokered credential reference.
|
||||
- **Log redaction.** Trace logs and telemetry run through `TRACE_SECRET_PATTERNS` before storage.
|
||||
- **No credentials in PR descriptions, test fixtures, or log snippets.**
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ The profile records stable profile and adapter IDs, transport, executable and
|
|||
non-mutating authentication probes, version/build invalidation policy,
|
||||
platforms, launch/worktree behavior, environment and credential allowlists,
|
||||
conformance fixture identity, documentation, and remediation. The contract
|
||||
contains credential key names only, never credential values.
|
||||
contains credential key names only, never credential values. Credential-like
|
||||
launch arguments are replaced with `[REDACTED]` before the profile is exposed
|
||||
or hashed, so rotating a secret cannot turn the profile digest into a secret
|
||||
oracle.
|
||||
|
||||
The live status projection uses five tiers:
|
||||
|
||||
|
|
@ -44,7 +47,10 @@ Task start rechecks the normalized profile before attempt state is created. An
|
|||
explicit provider must match the profile's executable adapter. A display-only
|
||||
Claude Code or Copilot profile, an unsupported provider, or an unknown
|
||||
provider-less profile fails with an actionable `409` and can never fall through
|
||||
to OpenClaw.
|
||||
to OpenClaw. Recognized credential material in the configured command or launch
|
||||
arguments degrades the profile and blocks dispatch before probing or attempt
|
||||
creation. Put credentials in an allowlisted environment key or a run-scoped
|
||||
brokered credential reference instead.
|
||||
|
||||
For backward compatibility, normalization migrates only known provider-less
|
||||
Codex and Hermes records when both the built-in type and command identity match
|
||||
|
|
|
|||
|
|
@ -36,4 +36,88 @@ describe('HarnessSupportProfileSchema', () => {
|
|||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('redacts credential-bearing launch arguments without encoding values in the digest', () => {
|
||||
const base: AgentConfig = {
|
||||
type: 'custom-secure-runner',
|
||||
name: 'Secure Runner',
|
||||
command: 'runner',
|
||||
args: [
|
||||
'--api-key',
|
||||
'first-sensitive-value',
|
||||
'--token=inline-sensitive-value',
|
||||
'--credentials=credential-sensitive-value',
|
||||
],
|
||||
enabled: true,
|
||||
provider: 'codex-cli',
|
||||
};
|
||||
const first = normalizeHarnessSupportProfile(base);
|
||||
const rotated = normalizeHarnessSupportProfile({
|
||||
...base,
|
||||
args: [
|
||||
'--api-key',
|
||||
'rotated-sensitive-value',
|
||||
'--token=inline-sensitive-value',
|
||||
'--credentials=rotated-credential-sensitive-value',
|
||||
],
|
||||
});
|
||||
|
||||
expect(first.launch.args).toEqual([
|
||||
'--api-key',
|
||||
'[REDACTED]',
|
||||
'--token=[REDACTED]',
|
||||
'--credentials=[REDACTED]',
|
||||
]);
|
||||
expect(JSON.stringify(first)).not.toContain('first-sensitive-value');
|
||||
expect(JSON.stringify(first)).not.toContain('inline-sensitive-value');
|
||||
expect(JSON.stringify(first)).not.toContain('credential-sensitive-value');
|
||||
expect(first.compatibility.configurationDigest).toBe(rotated.compatibility.configurationDigest);
|
||||
expect(first).toMatchObject({
|
||||
supportTier: 'degraded',
|
||||
supportReason: expect.stringMatching(/credential material/i),
|
||||
});
|
||||
expect(() => HarnessSupportProfileSchema.parse(first)).not.toThrow();
|
||||
});
|
||||
|
||||
it('redacts and degrades a command containing a credential argument', () => {
|
||||
const profile = normalizeHarnessSupportProfile({
|
||||
type: 'custom-secure-runner',
|
||||
name: 'Secure Runner',
|
||||
command:
|
||||
'runner token=inline-command-sensitive-value --authorization command-sensitive-value',
|
||||
args: [],
|
||||
enabled: true,
|
||||
provider: 'codex-cli',
|
||||
});
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
supportTier: 'degraded',
|
||||
executable: {
|
||||
command: 'runner token=[REDACTED] --authorization [REDACTED]',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(profile)).not.toContain('inline-command-sensitive-value');
|
||||
expect(JSON.stringify(profile)).not.toContain('command-sensitive-value');
|
||||
});
|
||||
|
||||
it('rejects unredacted credential material anywhere in public profile evidence', () => {
|
||||
const profile = normalizeHarnessSupportProfile({
|
||||
type: 'codex',
|
||||
name: 'OpenAI Codex',
|
||||
command: 'codex',
|
||||
args: [],
|
||||
enabled: true,
|
||||
provider: 'codex-cli',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
HarnessSupportProfileSchema.parse({
|
||||
...profile,
|
||||
conformance: {
|
||||
...profile.conformance,
|
||||
providerBuild: 'build token=unredacted-sensitive-value',
|
||||
},
|
||||
})
|
||||
).toThrow(/credentials|secrets/i);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -312,4 +312,34 @@ describe('evaluateHarnessSupportStatus', () => {
|
|||
expect(status.diagnosticCommands).toContain('codex login status --token=[REDACTED]');
|
||||
expect(JSON.stringify(status)).not.toContain('diagnostic-secret');
|
||||
});
|
||||
|
||||
it('degrades unsafe launch configuration before evaluating runtime readiness', () => {
|
||||
const candidate = agent({
|
||||
type: 'custom-secure-runner',
|
||||
name: 'Secure Runner',
|
||||
command: 'codex',
|
||||
args: ['--api-key', 'status-sensitive-value'],
|
||||
provider: 'codex-cli',
|
||||
});
|
||||
|
||||
const status = evaluateHarnessSupportStatus(candidate, {
|
||||
type: candidate.type,
|
||||
name: candidate.name,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
command: candidate.command,
|
||||
executableFound: true,
|
||||
authenticated: true,
|
||||
healthy: true,
|
||||
checkedAt: '2026-07-23T16:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(status).toMatchObject({
|
||||
profileId: 'openai-codex-cli',
|
||||
supportTier: 'degraded',
|
||||
failureClass: 'unsafe-configuration',
|
||||
reason: expect.stringMatching(/credential material/i),
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain('status-sensitive-value');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentConfig } from '@veritas-kanban/shared';
|
||||
import { ClawdbotAgentService } from '../services/clawdbot-agent-service.js';
|
||||
import type { AgentHealthChecker } from '../services/agent-health-service.js';
|
||||
|
|
@ -127,6 +127,29 @@ describe('ClawdbotAgentService provider runtime adapters', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('fails closed when launch arguments contain credential material', async () => {
|
||||
const checkAgent = vi.fn(health.checkAgent);
|
||||
|
||||
await expect(
|
||||
new ClawdbotAgentService({ checkAgent }).probeProviderRuntime({
|
||||
type: 'custom-secure-runner',
|
||||
name: 'Secure Runner',
|
||||
command: 'codex',
|
||||
args: ['--api-key', 'sensitive-launch-value'],
|
||||
enabled: true,
|
||||
provider: 'codex-cli',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: 'CONFLICT',
|
||||
details: expect.objectContaining({
|
||||
profileId: 'openai-codex-cli',
|
||||
reason: 'Credential material is not allowed in harness launch commands or arguments',
|
||||
}),
|
||||
});
|
||||
expect(checkAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a new custom provider-less profile even when its command is codex', async () => {
|
||||
await expect(
|
||||
new ClawdbotAgentService(health).probeProviderRuntime({
|
||||
|
|
|
|||
|
|
@ -124,8 +124,17 @@ export const HarnessSupportProfileSchema = z
|
|||
});
|
||||
}
|
||||
const publicText = [
|
||||
profile.displayName,
|
||||
profile.supportReason,
|
||||
profile.executable.command,
|
||||
...profile.executable.versionArgs,
|
||||
...(profile.authentication.commandArgs ?? []),
|
||||
profile.compatibility.policy,
|
||||
...profile.compatibility.testedVersions,
|
||||
...profile.launch.args,
|
||||
...(profile.conformance.providerVersion ? [profile.conformance.providerVersion] : []),
|
||||
...(profile.conformance.providerBuild ? [profile.conformance.providerBuild] : []),
|
||||
profile.documentationUrl,
|
||||
...profile.remediation,
|
||||
];
|
||||
if (publicText.some(containsUnredactedProviderRuntimeSecret)) {
|
||||
|
|
|
|||
|
|
@ -414,8 +414,8 @@ export class ClawdbotAgentService {
|
|||
model: profileLaunch.model ?? agentConfig.model,
|
||||
}
|
||||
: agentConfig;
|
||||
const agentHealth = await this.assertAgentAvailable(agent, profileAgentConfig);
|
||||
const provider = this.resolveAgentProvider(profileAgentConfig, agent);
|
||||
const agentHealth = await this.assertAgentAvailable(agent, profileAgentConfig);
|
||||
const adapter = this.resolveProviderAdapter(provider);
|
||||
const budgetService = getAgentBudgetService();
|
||||
const budgetPolicy = budgetService.resolve({
|
||||
|
|
@ -1194,8 +1194,8 @@ export class ClawdbotAgentService {
|
|||
agent: AgentType = agentConfig.type,
|
||||
surface: ProviderRuntimeSurface = 'task'
|
||||
): Promise<ProviderRuntimeManifest> {
|
||||
const health = await this.assertAgentAvailable(agent, agentConfig);
|
||||
const provider = this.resolveAgentProvider(agentConfig, agent);
|
||||
const health = await this.assertAgentAvailable(agent, agentConfig);
|
||||
return this.resolveProviderAdapter(provider, surface).probe({ agentConfig, health });
|
||||
}
|
||||
|
||||
|
|
@ -1281,6 +1281,19 @@ export class ClawdbotAgentService {
|
|||
// dispatch boundary. A caller-provided supportProfile may carry future
|
||||
// certification evidence, but it cannot authorize a different adapter.
|
||||
const profile = agentConfig ? normalizeHarnessSupportProfile(agentConfig) : undefined;
|
||||
if (profile?.supportTier === 'degraded') {
|
||||
throw new ConflictError(
|
||||
`Harness support profile "${profile.id}" has an unsafe launch configuration`,
|
||||
{
|
||||
agent,
|
||||
profileId: profile.id,
|
||||
adapterId: profile.adapterId,
|
||||
provider,
|
||||
reason: 'Credential material is not allowed in harness launch commands or arguments',
|
||||
remediation: profile.remediation,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (profile && profile.adapterId !== provider) {
|
||||
throw new ConflictError(
|
||||
`Harness support profile "${profile.id}" cannot dispatch through "${provider}"`,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
type HarnessSupportProfile,
|
||||
type HarnessTransport,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
containsUnredactedProviderRuntimeSecret,
|
||||
sanitizeProviderRuntimeDiagnostic,
|
||||
} from '../utils/provider-runtime-manifest-sanitize.js';
|
||||
|
||||
const ALL_PLATFORMS: HarnessSupportProfile['platforms'] = ['darwin', 'linux', 'win32'];
|
||||
const INVALIDATION_KEYS: HarnessSupportProfile['compatibility']['invalidateOn'] = [
|
||||
|
|
@ -50,6 +54,16 @@ interface ProfileDefinition {
|
|||
remediation: string[];
|
||||
}
|
||||
|
||||
interface RedactedLaunchArgs {
|
||||
args: string[];
|
||||
containsCredentialMaterial: boolean;
|
||||
}
|
||||
|
||||
interface RedactedCommand {
|
||||
command: string;
|
||||
containsCredentialMaterial: boolean;
|
||||
}
|
||||
|
||||
const DEFINITIONS: Record<string, ProfileDefinition> = {
|
||||
'claude-code': unsupported(
|
||||
'claude-code',
|
||||
|
|
@ -157,8 +171,12 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
|
|||
);
|
||||
|
||||
const executableProfile = Boolean(definition.adapterId);
|
||||
const redactedCommand = redactCommand(agent.command);
|
||||
const redactedLaunchArgs = redactLaunchArgs(agent.args);
|
||||
const unsafeLaunchConfiguration =
|
||||
redactedCommand.containsCredentialMaterial || redactedLaunchArgs.containsCredentialMaterial;
|
||||
const executable = {
|
||||
command: agent.command,
|
||||
command: redactedCommand.command,
|
||||
versionArgs: ['--version'],
|
||||
};
|
||||
const authentication = {
|
||||
|
|
@ -166,7 +184,7 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
|
|||
nonMutating: true as const,
|
||||
};
|
||||
const launch = {
|
||||
args: [...agent.args],
|
||||
args: redactedLaunchArgs.args,
|
||||
workingDirectory: 'task-worktree' as const,
|
||||
worktree: 'required' as const,
|
||||
environmentAllowlist: [
|
||||
|
|
@ -188,6 +206,10 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
|
|||
platforms: ALL_PLATFORMS,
|
||||
launch,
|
||||
});
|
||||
const unsafeConfigurationReason =
|
||||
'Credential material is not allowed in harness launch commands or arguments.';
|
||||
const unsafeConfigurationRemediation =
|
||||
'Remove credential values from launch arguments and use an allowlisted environment key or run-scoped credential reference.';
|
||||
|
||||
return {
|
||||
schemaVersion: HARNESS_SUPPORT_PROFILE_SCHEMA_VERSION,
|
||||
|
|
@ -195,10 +217,16 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
|
|||
displayName: definition.displayName,
|
||||
...(definition.adapterId ? { adapterId: definition.adapterId } : {}),
|
||||
transport: definition.transport,
|
||||
supportTier: executableProfile ? 'configured' : 'unsupported',
|
||||
supportReason: executableProfile
|
||||
? 'An explicit executable adapter is registered; live readiness requires a runtime probe.'
|
||||
: (definition.remediation[0] ?? 'No executable adapter is registered.'),
|
||||
supportTier: !executableProfile
|
||||
? 'unsupported'
|
||||
: unsafeLaunchConfiguration
|
||||
? 'degraded'
|
||||
: 'configured',
|
||||
supportReason: !executableProfile
|
||||
? (definition.remediation[0] ?? 'No executable adapter is registered.')
|
||||
: unsafeLaunchConfiguration
|
||||
? unsafeConfigurationReason
|
||||
: 'An explicit executable adapter is registered; live readiness requires a runtime probe.',
|
||||
executable,
|
||||
authentication,
|
||||
compatibility: {
|
||||
|
|
@ -215,10 +243,65 @@ export function normalizeHarnessSupportProfile(agent: AgentConfig): HarnessSuppo
|
|||
status: 'not-run',
|
||||
},
|
||||
documentationUrl: definition.documentationUrl,
|
||||
remediation: [...definition.remediation],
|
||||
remediation: [
|
||||
...(unsafeLaunchConfiguration ? [unsafeConfigurationRemediation] : []),
|
||||
...definition.remediation,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function redactCommand(command: string): RedactedCommand {
|
||||
const containsDiagnosticSecret = containsUnredactedProviderRuntimeSecret(command);
|
||||
const sanitized = containsDiagnosticSecret ? sanitizeProviderRuntimeDiagnostic(command) : command;
|
||||
const redacted = redactLaunchArgs(sanitized.trim().split(/\s+/));
|
||||
return containsDiagnosticSecret || redacted.containsCredentialMaterial
|
||||
? {
|
||||
command: redacted.args.join(' '),
|
||||
containsCredentialMaterial: true,
|
||||
}
|
||||
: {
|
||||
command,
|
||||
containsCredentialMaterial: false,
|
||||
};
|
||||
}
|
||||
|
||||
function redactLaunchArgs(args: string[]): RedactedLaunchArgs {
|
||||
const redacted: string[] = [];
|
||||
let redactNext = false;
|
||||
let containsCredentialMaterial = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (redactNext) {
|
||||
redacted.push('[REDACTED]');
|
||||
redactNext = false;
|
||||
containsCredentialMaterial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = arg.trim();
|
||||
const credentialArgument = normalized.match(
|
||||
/^(--?(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|authorization|credentials?))(?:=(.*))?$/i
|
||||
);
|
||||
if (credentialArgument) {
|
||||
const [, flag, inlineValue] = credentialArgument;
|
||||
redacted.push(inlineValue === undefined ? flag : `${flag}=[REDACTED]`);
|
||||
redactNext = inlineValue === undefined;
|
||||
containsCredentialMaterial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (containsUnredactedProviderRuntimeSecret(arg)) {
|
||||
redacted.push(sanitizeProviderRuntimeDiagnostic(arg));
|
||||
containsCredentialMaterial = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
redacted.push(arg);
|
||||
}
|
||||
|
||||
return { args: redacted, containsCredentialMaterial };
|
||||
}
|
||||
|
||||
function digestConfiguration(value: unknown): string {
|
||||
return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ export function evaluateHarnessSupportStatus(
|
|||
);
|
||||
}
|
||||
|
||||
if (profile.supportTier === 'degraded') {
|
||||
return status(base, 'degraded', 'unsafe-configuration', profile.supportReason);
|
||||
}
|
||||
|
||||
if (!health.executableFound) {
|
||||
return status(
|
||||
base,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export type HarnessSupportFailureClass =
|
|||
| 'unauthenticated'
|
||||
| 'incompatible-build'
|
||||
| 'adapter-unavailable'
|
||||
| 'unsafe-configuration'
|
||||
| 'probe-failed'
|
||||
| 'launch-failed'
|
||||
| 'run-failed'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue