Skip to content

Commit 35bb962

Browse files
lpcoxCopilotCopilot
authored
fix: complete api-proxy config follow-ups and harden DinD identity synthesis (#4063)
* fix: synthesize identity files for ARC-DinD environments When running in ARC DinD mode, the Docker daemon's filesystem often lacks the runner's UID in /etc/passwd and /etc/group. This causes 'getent passwd' to fail and the agent to run as 'nobody'. Two-layer fix: - etc-mounts.ts: synthesize minimal passwd/group when staging fails (source files don't exist on the DinD daemon's filesystem) - entrypoint.sh: runtime fallback synthesizes identity entries when getent passwd fails inside the chroot The synthesis creates entries for root and the runner user (matching the host UID/GID) so the agent process has a valid username and home. Closes #4022 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add excludeEngines to modelFallback TypeScript types The modelFallback.excludeEngines field was present in the JSON schema and spec (added in PR #4015) but missing from the TypeScript interfaces in src/types/api-proxy-options.ts and src/config-file.ts. The runtime was not affected (JSON.stringify passthrough), but type-safety was incomplete. - Add excludeEngines?: string[] to modelFallback in api-proxy-options.ts - Add excludeEngines?: string[] to modelFallback in config-file.ts - Add config-file-mapping test for excludeEngines passthrough Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add daily config consistency auditor workflow Adds an agentic workflow that runs daily on weekdays to audit recently merged PRs for configuration consistency gaps. When a new config field is introduced in one layer but missing from others (schema, spec, TypeScript types, or env var wiring), the workflow fixes the gaps and opens a PR. Checks: - JSON schema (src/ and docs/ copies must be identical) - Spec CLI mapping table (docs/awf-config-spec.md §5) - TypeScript types (src/types/*.ts, src/config-file.ts) - Env var wiring (src/services/api-proxy-service.ts) - Security classification (sensitive via env vars, non-sensitive via stdin) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback on identity synthesis and token guard --------- 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 0aa54a5 commit 35bb962

10 files changed

Lines changed: 1663 additions & 24 deletions

File tree

.github/workflows/config-consistency-auditor.lock.yml

Lines changed: 1318 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
---
2+
name: Config Consistency Auditor
3+
description: >
4+
Daily audit of recently merged PRs to verify new configuration is consistently
5+
represented across JSON schema, spec, TypeScript types, and env var wiring —
6+
with security-sensitive values via env vars and non-sensitive via stdin config.
7+
on:
8+
schedule: daily on weekdays
9+
workflow_dispatch:
10+
permissions:
11+
contents: read
12+
pull-requests: read
13+
issues: read
14+
engine: copilot
15+
strict: true
16+
timeout-minutes: 20
17+
network:
18+
allowed:
19+
- defaults
20+
- node
21+
- github
22+
tools:
23+
github:
24+
mode: gh-proxy
25+
toolsets: [default, pull_requests]
26+
cache-memory: true
27+
bash: ["*"]
28+
edit:
29+
safe-outputs:
30+
threat-detection:
31+
enabled: false
32+
create-pull-request:
33+
max: 1
34+
labels: [automation, config-consistency]
35+
title-prefix: "fix: "
36+
---
37+
38+
# Config Consistency Auditor
39+
40+
You are an AI agent that audits recently merged PRs for configuration consistency.
41+
Your goal is to catch gaps where new configuration was added to one layer but not
42+
propagated to all required layers.
43+
44+
## Configuration Layers
45+
46+
Every new AWF configuration field MUST be consistently represented across:
47+
48+
1. **JSON Schema** (`src/awf-config-schema.json` and `docs/awf-config.schema.json`)
49+
- Must be identical copies
50+
2. **Spec** (`docs/awf-config-spec.md`)
51+
- Section 5 CLI Mapping table must list the config path and its CLI flag or env var mapping
52+
3. **TypeScript Types** (`src/types/*.ts` and `src/config-file.ts`)
53+
- The config-file interface must include the field
54+
- The options type must include the mapped CLI option
55+
4. **Env Var Wiring** (`src/services/api-proxy-service.ts` or other service files)
56+
- The field must be mapped to its corresponding `AWF_*` env var for the api-proxy
57+
- OR mapped to a CLI flag that the runtime handles
58+
59+
## Security Classification
60+
61+
Configuration fields MUST follow these rules:
62+
63+
- **Security-sensitive values** (API keys, tokens, credentials, OIDC client IDs/secrets):
64+
- Passed via environment variables (`-e` flag or `--env-file`)
65+
- MUST NOT appear in stdin config JSON (which may be logged)
66+
- **Non-sensitive values** (domains, multipliers, model names, timeouts, strategies):
67+
- Passed via stdin config (`--config -`)
68+
- Mapped in `src/config-file.ts`
69+
70+
## Procedure
71+
72+
### 1. Load last-processed state
73+
74+
Read `/tmp/gh-aw/cache-memory/config-audit-state.json`. It stores:
75+
```json
76+
{ "last_audit_date": "YYYY-MM-DD", "last_pr_number": 1234 }
77+
```
78+
79+
- If the file exists, audit PRs merged since `last_audit_date`.
80+
- If the file does NOT exist (first run), audit PRs merged in the **last 7 days**.
81+
82+
### 2. Fetch recently merged PRs
83+
84+
```bash
85+
gh pr list --repo github/gh-aw-firewall --state merged --limit 20 \
86+
--json number,title,mergedAt,files --jq '.[] | select(.mergedAt > "CUTOFF_DATE")'
87+
```
88+
89+
Filter to PRs that modify any of these paths (likely to introduce config):
90+
- `src/config-file.ts`
91+
- `src/types/*.ts`
92+
- `src/awf-config-schema.json`
93+
- `docs/awf-config-spec.md`
94+
- `docs/awf-config.schema.json`
95+
- `src/services/api-proxy-service.ts`
96+
- `src/cli-options.ts` or `src/cli.ts`
97+
- `containers/api-proxy/server.js`
98+
- `containers/api-proxy/guards/*.js`
99+
100+
If no relevant PRs are found, save state and exit with `noop`.
101+
102+
### 3. For each relevant PR, check consistency
103+
104+
For each PR, examine what new configuration was introduced by reading the diff:
105+
106+
```bash
107+
gh pr diff <NUMBER> --repo github/gh-aw-firewall
108+
```
109+
110+
Look for patterns indicating new config:
111+
- New properties in schema JSON (`"propertyName": { "type":`)
112+
- New rows in spec CLI mapping table
113+
- New fields in TypeScript interfaces
114+
- New `AWF_*` env var assignments
115+
- New CLI `.option(` definitions
116+
117+
### 4. Cross-reference all layers
118+
119+
For each new configuration field found, verify it exists in ALL required layers:
120+
121+
| Check | How to verify |
122+
|-------|---------------|
123+
| JSON Schema (src) | `grep "fieldName" src/awf-config-schema.json` |
124+
| JSON Schema (docs) | Schemas must be identical: `diff src/awf-config-schema.json docs/awf-config.schema.json` |
125+
| Spec CLI mapping | `grep "fieldName" docs/awf-config-spec.md` |
126+
| TypeScript type | `grep "fieldName" src/types/*.ts src/config-file.ts` |
127+
| Env var wiring | `grep "AWF_FIELD_NAME" src/services/api-proxy-service.ts` (for api-proxy config) |
128+
129+
### 5. Check security classification
130+
131+
For each new field, determine if it's security-sensitive:
132+
- Contains "key", "secret", "token", "credential", "password" → security-sensitive
133+
- Is an OIDC client ID or tenant ID → security-sensitive
134+
- Is a domain, multiplier, timeout, strategy, model name → non-sensitive
135+
136+
Verify:
137+
- Security-sensitive fields are passed via env vars (not in config-file.ts stdin mapping)
138+
- Non-sensitive fields are in config-file.ts (stdin config mapping)
139+
140+
### 6. Fix gaps and create a PR
141+
142+
If gaps are found, fix them directly:
143+
144+
- **Missing TypeScript type field**: Add the field to the appropriate interface in
145+
`src/types/*.ts` and/or `src/config-file.ts`
146+
- **Missing spec CLI mapping row**: Add the row to Section 5 of `docs/awf-config-spec.md`
147+
- **Missing schema field**: Add the property to `src/awf-config-schema.json` AND
148+
`docs/awf-config.schema.json` (they must stay identical)
149+
- **Missing env var wiring**: Add the mapping in `src/services/api-proxy-service.ts`
150+
- **Schema drift**: Copy `src/awf-config-schema.json` to `docs/awf-config.schema.json`
151+
152+
After making fixes, use the `create-pull-request` safe output with:
153+
- Title: `"fix: propagate config fields to all layers"`
154+
- Body: A summary table of what was fixed, organized by PR that introduced the gap
155+
156+
Example PR body:
157+
```markdown
158+
## Config Consistency Fixes
159+
160+
Automated fixes for configuration fields not fully propagated:
161+
162+
### From PR #1234 — "feat: add fooBar config"
163+
164+
| Field | Fix Applied |
165+
|-------|-------------|
166+
| `apiProxy.fooBar` | Added to TypeScript interface in `src/types/api-proxy-options.ts` |
167+
168+
### Verification
169+
170+
- [ ] TypeScript compiles (`tsc --noEmit`)
171+
- [ ] Config-file-mapping tests pass
172+
- [ ] Schema validation tests pass
173+
```
174+
175+
If no gaps are found, use `noop` safe output.
176+
177+
### 7. Save state
178+
179+
Write the current date and highest PR number to
180+
`/tmp/gh-aw/cache-memory/config-audit-state.json`:
181+
```json
182+
{ "last_audit_date": "YYYY-MM-DD", "last_pr_number": 4063 }
183+
```
184+
185+
## Important Notes
186+
187+
- Internal refactors (renaming files, moving code between modules) that don't add
188+
new user-facing config should be ignored.
189+
- Test-only changes (new test files, test helpers) should be ignored.
190+
- The `docs/awf-config.schema.json` and `src/awf-config-schema.json` MUST always be
191+
identical. If they differ, report that as a critical gap.
192+
- Fields that are intentionally runtime-only (no config equivalent) should be noted
193+
but not flagged as gaps if documented in the spec as "CLI-only".

containers/agent/entrypoint.sh

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,10 +715,27 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
715715
# User not found in chroot's /etc/passwd (common on ARC-DinD Alpine daemons).
716716
# Synthesize minimal identity files so the agent can resolve its own UID/GID.
717717
HOST_USER="runner"
718+
if [ -f /host/etc/passwd ] && grep -q "^${HOST_USER}:" /host/etc/passwd 2>/dev/null; then
719+
HOST_USER="runner-${HOST_USER_UID}"
720+
local_user_suffix=1
721+
while grep -q "^${HOST_USER}:" /host/etc/passwd 2>/dev/null; do
722+
HOST_USER="runner-${HOST_USER_UID}-${local_user_suffix}"
723+
local_user_suffix=$((local_user_suffix + 1))
724+
done
725+
fi
718726
echo "[entrypoint] User with UID ${HOST_USER_UID} not found in chroot — synthesizing identity files"
719727

720728
# Determine the user's home directory (default to /home/runner)
721729
SYNTH_HOME="${AWF_HOST_HOME:-/home/${HOST_USER}}"
730+
SYNTH_GROUP_NAME="${HOST_USER}"
731+
if [ -f /host/etc/group ] && grep -q "^${SYNTH_GROUP_NAME}:" /host/etc/group 2>/dev/null; then
732+
SYNTH_GROUP_NAME="runner-${HOST_USER_GID}"
733+
local_group_suffix=1
734+
while grep -q "^${SYNTH_GROUP_NAME}:" /host/etc/group 2>/dev/null; do
735+
SYNTH_GROUP_NAME="runner-${HOST_USER_GID}-${local_group_suffix}"
736+
local_group_suffix=$((local_group_suffix + 1))
737+
done
738+
fi
722739

723740
# Synthesize /etc/passwd entry if missing
724741
if ! grep -q "^[^:]*:[^:]*:${HOST_USER_UID}:" /host/etc/passwd 2>/dev/null; then
@@ -743,17 +760,17 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
743760

744761
# Synthesize /etc/group entry if missing
745762
if ! grep -q "^[^:]*:[^:]*:${HOST_USER_GID}:" /host/etc/group 2>/dev/null; then
746-
GROUP_ENTRY="${HOST_USER}:x:${HOST_USER_GID}:"
763+
GROUP_ENTRY="${SYNTH_GROUP_NAME}:x:${HOST_USER_GID}:"
747764
if [ -f /host/etc/group ]; then
748765
if echo "${GROUP_ENTRY}" >> /host/etc/group 2>/dev/null; then
749-
echo "[entrypoint] Appended group ${HOST_USER} (GID ${HOST_USER_GID}) to /host/etc/group"
766+
echo "[entrypoint] Appended group ${SYNTH_GROUP_NAME} (GID ${HOST_USER_GID}) to /host/etc/group"
750767
else
751768
echo "[entrypoint][WARN] Could not write to /host/etc/group"
752769
fi
753770
else
754771
if printf '%s\n' "root:x:0:" "nobody:x:65534:" "${GROUP_ENTRY}" > /host/etc/group 2>/dev/null; then
755772
chmod 644 /host/etc/group 2>/dev/null
756-
echo "[entrypoint] Created /host/etc/group with group ${HOST_USER} (GID ${HOST_USER_GID})"
773+
echo "[entrypoint] Created /host/etc/group with group ${SYNTH_GROUP_NAME} (GID ${HOST_USER_GID})"
757774
else
758775
echo "[entrypoint][WARN] Could not create /host/etc/group"
759776
fi

containers/api-proxy/guards/effective-token-guard.js

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function createEffectiveTokenState(configKey = null) {
2525
totalEffectiveTokens: 0,
2626
emittedThresholds: new Set(),
2727
uninjectedThresholds: new Set(),
28+
warnedUnknownModels: new Set(),
2829
};
2930
}
3031

@@ -77,11 +78,10 @@ function getEffectiveTokenConfig() {
7778
effectiveTokenConfigCache.rawDefaultMultiplier = rawDefaultMultiplier;
7879
const parsedMultipliers = Object.freeze(parseModelMultipliers(rawMultipliers));
7980
const configuredDefaultMultiplier = parsePositiveNumber(rawDefaultMultiplier);
80-
const maxConfiguredMultiplier = Math.max(1, ...Object.values(parsedMultipliers));
8181
effectiveTokenConfigCache.parsed = {
8282
max: parsePositiveInteger(rawMax),
8383
multipliers: parsedMultipliers,
84-
defaultMultiplier: configuredDefaultMultiplier ?? maxConfiguredMultiplier,
84+
defaultMultiplier: configuredDefaultMultiplier ?? 1,
8585
};
8686
return effectiveTokenConfigCache.parsed;
8787
}
@@ -95,7 +95,7 @@ function getEffectiveTokenState(config) {
9595
return etGuardState;
9696
}
9797

98-
function resolveModelMultiplier(model, config) {
98+
function resolveModelMultiplier(model, config, state = null) {
9999
if (Object.hasOwn(config.multipliers, model)) {
100100
return { multiplier: config.multipliers[model], source: 'exact' };
101101
}
@@ -111,17 +111,21 @@ function resolveModelMultiplier(model, config) {
111111

112112
if (prefixMatch) return prefixMatch;
113113

114-
logRequest('warn', 'unknown_model_multiplier', {
115-
model: sanitizeForLog(model),
116-
applied_multiplier: config.defaultMultiplier,
117-
default_model_multiplier: config.defaultMultiplier,
118-
});
114+
const shouldLog = !state || !state.warnedUnknownModels.has(model);
115+
if (shouldLog) {
116+
logRequest('warn', 'unknown_model_multiplier', {
117+
model: sanitizeForLog(model),
118+
applied_multiplier: config.defaultMultiplier,
119+
default_model_multiplier: config.defaultMultiplier,
120+
});
121+
state?.warnedUnknownModels.add(model);
122+
}
119123

120124
return { multiplier: config.defaultMultiplier, source: 'default' };
121125
}
122126

123-
function calculateEffectiveTokens(normalizedUsage, model, config) {
124-
const multiplierResolution = resolveModelMultiplier(model, config);
127+
function calculateEffectiveTokens(normalizedUsage, model, config, state = null) {
128+
const multiplierResolution = resolveModelMultiplier(model, config, state);
125129
const multiplier = multiplierResolution.multiplier;
126130
const baseWeightedTokens =
127131
(ET_DEFAULT_WEIGHTS.input * (normalizedUsage.input_tokens || 0)) +
@@ -141,7 +145,7 @@ function applyEffectiveTokenUsage(normalizedUsage, model) {
141145
if (!state || !normalizedUsage) return null;
142146

143147
const previousTotal = state.totalEffectiveTokens;
144-
const calc = calculateEffectiveTokens(normalizedUsage, model || 'unknown', config);
148+
const calc = calculateEffectiveTokens(normalizedUsage, model || 'unknown', config, state);
145149
state.totalEffectiveTokens += calc.effectiveTokens;
146150
const percentUsed = (state.totalEffectiveTokens / config.max) * 100;
147151

containers/api-proxy/guards/effective-token-guard.test.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('effective-token-guard reflect state', () => {
7676
});
7777
});
7878

79-
it('uses the highest configured multiplier for unknown models by default and warns', () => {
79+
it('uses multiplier 1 for unknown models when explicit default is unset and warns', () => {
8080
const { lines } = collectLogOutput();
8181
process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000';
8282
process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({
@@ -86,13 +86,13 @@ describe('effective-token-guard reflect state', () => {
8686

8787
const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'unmapped-expensive-model');
8888

89-
expect(usage.modelMultiplier).toBe(54);
90-
expect(usage.effectiveTokensThisResponse).toBe(216);
89+
expect(usage.modelMultiplier).toBe(1);
90+
expect(usage.effectiveTokensThisResponse).toBe(4);
9191
expect(lines).toContainEqual(expect.objectContaining({
9292
event: 'unknown_model_multiplier',
9393
level: 'warn',
9494
model: 'unmapped-expensive-model',
95-
applied_multiplier: 54,
95+
applied_multiplier: 1,
9696
}));
9797
});
9898

@@ -145,4 +145,20 @@ describe('effective-token-guard reflect state', () => {
145145
expect(usage.modelMultiplier).toBe(27);
146146
expect(lines.find((line) => line.event === 'unknown_model_multiplier')).toBeUndefined();
147147
});
148+
it('logs unknown model multiplier once per model per config state', () => {
149+
const { lines } = collectLogOutput();
150+
process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000';
151+
process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER = '27';
152+
153+
applyEffectiveTokenUsage({ output_tokens: 1 }, 'unknown-model');
154+
applyEffectiveTokenUsage({ output_tokens: 2 }, 'unknown-model');
155+
applyEffectiveTokenUsage({ output_tokens: 1 }, 'other-unknown-model');
156+
157+
const unknownLogs = lines.filter((line) => line.event === 'unknown_model_multiplier');
158+
expect(unknownLogs).toHaveLength(2);
159+
expect(unknownLogs).toEqual(expect.arrayContaining([
160+
expect.objectContaining({ model: 'unknown-model' }),
161+
expect.objectContaining({ model: 'other-unknown-model' }),
162+
]));
163+
});
148164
});

src/config-file-mapping.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ describe('mapAwfFileConfigToCliOptions', () => {
168168
expect(result.modelFallback).toEqual({ enabled: false, strategy: 'middle_power' });
169169
});
170170

171+
it('maps modelFallback.excludeEngines field', () => {
172+
const result = mapAwfFileConfigToCliOptions({
173+
apiProxy: {
174+
modelFallback: { enabled: true, strategy: 'middle_power', excludeEngines: ['openai', 'copilot'] },
175+
},
176+
});
177+
expect(result.modelFallback).toEqual({
178+
enabled: true,
179+
strategy: 'middle_power',
180+
excludeEngines: ['openai', 'copilot'],
181+
});
182+
});
183+
171184
it('leaves maxRuns undefined when not set', () => {
172185
const result = mapAwfFileConfigToCliOptions({});
173186
expect(result.maxRuns).toBeUndefined();

src/config-file.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface AwfFileConfig {
2525
modelFallback?: {
2626
enabled?: boolean;
2727
strategy?: 'middle_power';
28+
excludeEngines?: string[];
2829
};
2930
targets?: {
3031
openai?: { host?: string; basePath?: string; authHeader?: string };

0 commit comments

Comments
 (0)