Skip to content

Commit e99d0a8

Browse files
lpcoxCopilotCopilot
authored
feat: persist redacted resolved config as audit artifact (#4719)
* feat: persist redacted resolved config as audit artifact Write awf-resolved-config.json (mode 0600) to the audit dir (or workDir if no audit dir is set) at startup, before containers launch. This allows post-run diagnostics to recover the full set of CLI options, domain allowlists, network topology, and feature flags without requiring --log-level debug or parsing step logs. Security-sensitive fields (API keys, tokens) are excluded using the same redaction logic already applied to the debug log. The agentCommand field has secrets within it redacted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update gh-aw-actions/setup SHA in workflow tests to v0.79.4 The recompile PR #4714 bumped the pinned action from v0.79.2 to v0.79.4 but didn't update the corresponding test assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use module-level fs mocks, correct audit dir and permissions --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 0e91322 commit e99d0a8

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

scripts/ci/security-guard-workflow.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('security guard workflow optimization config', () => {
3838
expect(lock).toContain('--max-turns 6');
3939
expect(lock).toContain('ANTHROPIC_MODEL: claude-sonnet-4-5');
4040
expect(lock).toContain('GH_AW_MAX_TURNS: 6');
41-
expect(lock).toContain('github/gh-aw-actions/setup@9b1d730701c16b15673633e5696d01677fad9844 # v0.79.2');
41+
expect(lock).toContain('github/gh-aw-actions/setup@d059700c6a8ec3b5fd798b9ea60f5d048447b918 # v0.79.4');
4242
expect(lock).not.toContain('github/gh-aw-actions/setup@v0.79.2');
4343
expect(lock).toContain('ghcr.io/github/github-mcp-server:v1.1.2');
4444
});

scripts/ci/test-coverage-improver-workflow.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('test coverage improver workflow token optimization config', () => {
7575
expect(lock).not.toContain("shell(cat:src/*.test.ts)");
7676
expect(lock).not.toContain("shell(npm run lint)");
7777
expect(lock).not.toContain("shell(npm run test)");
78-
expect(lock).toContain('github/gh-aw-actions/setup@9b1d730701c16b15673633e5696d01677fad9844 # v0.79.2');
78+
expect(lock).toContain('github/gh-aw-actions/setup@d059700c6a8ec3b5fd798b9ea60f5d048447b918 # v0.79.4');
7979
expect(lock).not.toContain('github/gh-aw-actions/setup@v0.79.2');
8080
expect(lock).toContain('ghcr.io/github/github-mcp-server:v1.1.2');
8181

src/commands/main-action.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
// Module-level mock functions for fs — must be declared before jest.mock('fs')
2+
// so the factory can close over them. jest.mock is hoisted but the factory runs
3+
// lazily after module initialisation, when these variables are defined.
4+
const mockMkdirSync = jest.fn();
5+
const mockWriteFileSync = jest.fn();
6+
const mockChmodSync = jest.fn();
7+
8+
jest.mock('fs', () => {
9+
const actual = jest.requireActual<typeof import('fs')>('fs');
10+
return {
11+
...actual,
12+
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
13+
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
14+
chmodSync: (...args: unknown[]) => mockChmodSync(...args),
15+
};
16+
});
17+
118
import { createMainAction } from './main-action';
219

320
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -373,4 +390,81 @@ describe('createMainAction', () => {
373390
expect(serialized).not.toContain('gem-secret');
374391
});
375392
});
393+
394+
describe('resolved config artifact', () => {
395+
beforeEach(() => {
396+
mockMkdirSync.mockReset();
397+
mockWriteFileSync.mockReset();
398+
mockChmodSync.mockReset();
399+
});
400+
401+
afterEach(() => jest.restoreAllMocks());
402+
403+
it('writes awf-resolved-config.json to audit dir when set', async () => {
404+
const configWithAudit = {
405+
...STUB_CONFIG,
406+
auditDir: '/tmp/awf-audit',
407+
};
408+
mockedValidateOptions.validateOptions.mockReturnValue(
409+
configWithAudit as unknown as import('../types').WrapperConfig
410+
);
411+
const action = createMainAction(getOptionValueSource);
412+
await action(['echo hi'], {});
413+
414+
expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/awf-audit', { recursive: true, mode: 0o755 });
415+
expect(mockWriteFileSync).toHaveBeenCalledWith(
416+
'/tmp/awf-audit/awf-resolved-config.json',
417+
expect.stringContaining('"allowedDomains"'),
418+
{ mode: 0o644 },
419+
);
420+
expect(mockChmodSync).toHaveBeenCalledWith('/tmp/awf-audit/awf-resolved-config.json', 0o644);
421+
// Verify secret key names are excluded from the artifact
422+
const written = mockWriteFileSync.mock.calls.find(
423+
(c) => String(c[0]).includes('awf-resolved-config.json')
424+
);
425+
expect(written).toBeDefined();
426+
const writtenJson = String(written![1]);
427+
expect(writtenJson).not.toContain('ApiKey');
428+
expect(writtenJson).not.toContain('GithubToken');
429+
});
430+
431+
it('redacts secret values in agentCommand in the artifact', async () => {
432+
const secretValue = 'super-secret-token-12345';
433+
const configWithSecret = {
434+
...STUB_CONFIG,
435+
auditDir: '/tmp/awf-audit',
436+
agentCommand: `my-agent --token ${secretValue}`,
437+
};
438+
mockedValidateOptions.validateOptions.mockReturnValue(
439+
configWithSecret as unknown as import('../types').WrapperConfig
440+
);
441+
// Make redactSecrets actually remove the secret value
442+
mockedRedactSecrets.redactSecrets.mockImplementation((s: string) =>
443+
s.replace(secretValue, '[REDACTED]')
444+
);
445+
446+
const action = createMainAction(getOptionValueSource);
447+
await action(['echo hi'], {});
448+
449+
const written = mockWriteFileSync.mock.calls.find(
450+
(c) => String(c[0]).includes('awf-resolved-config.json')
451+
);
452+
expect(written).toBeDefined();
453+
const writtenJson = String(written![1]);
454+
expect(writtenJson).not.toContain(secretValue);
455+
expect(writtenJson).toContain('[REDACTED]');
456+
});
457+
458+
it('falls back to workDir/audit when auditDir is not set', async () => {
459+
mockedValidateOptions.validateOptions.mockReturnValue(STUB_CONFIG);
460+
const action = createMainAction(getOptionValueSource);
461+
await action(['echo hi'], {});
462+
463+
expect(mockWriteFileSync).toHaveBeenCalledWith(
464+
'/tmp/awf-test/audit/awf-resolved-config.json',
465+
expect.any(String),
466+
{ mode: 0o644 },
467+
);
468+
});
469+
});
376470
});

src/commands/main-action.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
13
import { logger } from '../logger';
24
import {
35
writeConfigs,
@@ -115,6 +117,22 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) {
115117
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;
116118
}
117119
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));
120+
121+
// Persist redacted config to audit artifact for post-run diagnostics
122+
try {
123+
const configArtifactDir = config.auditDir || path.join(config.workDir, 'audit');
124+
fs.mkdirSync(configArtifactDir, { recursive: true, mode: 0o755 });
125+
const configArtifactPath = path.join(configArtifactDir, 'awf-resolved-config.json');
126+
fs.writeFileSync(
127+
configArtifactPath,
128+
JSON.stringify(redactedConfig, null, 2) + '\n',
129+
{ mode: 0o644 },
130+
);
131+
fs.chmodSync(configArtifactPath, 0o644);
132+
} catch (err) {
133+
logger.debug(`Failed to write resolved config artifact: ${err}`);
134+
}
135+
118136
logger.info(`Allowed domains: ${config.allowedDomains.join(', ')}`);
119137
if (config.blockedDomains && config.blockedDomains.length > 0) {
120138
logger.info(`Blocked domains: ${config.blockedDomains.join(', ')}`);

0 commit comments

Comments
 (0)