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
38 changes: 32 additions & 6 deletions hypaware-core/plugins-workspace/codex/src/exchange-projector.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export function createCodexExchangeProjector(opts = {}) {
/**
* @param {AiGatewayExchangeInput} input
* @param {{
* log?: { info?: (m: string, f?: Record<string, unknown>) => void },
* log?: {
* info?: (m: string, f?: Record<string, unknown>) => void,
* warn?: (m: string, f?: Record<string, unknown>) => void,
* },
* isSessionIgnored?: (sessionId: string) => boolean,
* }} [ctx]
*/
Expand Down Expand Up @@ -119,6 +122,19 @@ export function createCodexExchangeProjector(opts = {}) {
// non-codex traffic never scans.
const cwd = firstString(codexContext?.cwd, readRecordedCwd(reqBody))
?? (codexContext?.session_id ? rolloutCwd?.resolve(codexContext.session_id) : undefined)
// @ref LLP 0083#decision [implements]: a refused workspace substitution is
// observable, not silent - it means the gate is measuring a different
// directory than it would have. Paths are hashed: this seam sees LLM traffic.
if (codexContext?.refused_workspace_cwd) {
ctx?.log?.warn?.('plugin.codex.usage_policy_workspace_cwd_refused', {
component: 'codex',
operation: 'usage_policy_workspace_cwd_refused',
error_kind: 'workspace_cwd_mismatch',
workspace_sha256: sha256Hex(codexContext.refused_workspace_cwd).slice(0, 16),
cwd_sha256: cwd ? sha256Hex(cwd).slice(0, 16) : undefined,
exchange_id: input.exchange_id,
})
}
if (cwd) {
const policy = resolver.resolve(cwd)
if (policy.class === 'ignore') {
Expand Down Expand Up @@ -646,10 +662,8 @@ function resolveCodexContext(input, provider, path, reqBody) {
const metadata = readCodexTurnMetadata(input)
const userAgent = readHeader(input.request_headers, 'user-agent')
const client = codexClientFromUserAgent(userAgent)
const workspace = selectCodexWorkspace(
metadata,
firstString(readRecordedCwd(reqBody), readStringKey(metadata, 'cwd'))
)
const inBandCwd = firstString(readRecordedCwd(reqBody), readStringKey(metadata, 'cwd'))
const workspace = selectCodexWorkspace(metadata, inBandCwd)
const workspaceInfo = workspace?.info
const remoteUrls = isPlainObject(workspaceInfo?.associated_remote_urls)
? workspaceInfo.associated_remote_urls
Expand Down Expand Up @@ -709,7 +723,19 @@ function resolveCodexContext(input, provider, path, reqBody) {
parent_thread_id,
turn_id,
thread_source,
cwd: workspace?.path,
// @ref LLP 0083#decision [implements]: an explicit in-band cwd outranks the
// workspace key for the ONE resolved cwd (gate + stamp). `selectCodexWorkspace`
// substitutes the first `workspaces` key when none matches, which is a guess
// about a directory the session may never have run in, so it must not decide
// a `.hypignore` verdict. The key still enriches (`attributes.codex.workspace`,
// git_*) and still supplies the cwd on the subscription route, where the
// request states none and the key is the only in-band source there is.
cwd: firstString(inBandCwd, workspace?.path),
// Set only when the substitution was refused, so the caller can log it
// rather than let a discarded guess vanish.
refused_workspace_cwd: workspace && inBandCwd && !pathsEqual(workspace.path, inBandCwd)
? workspace.path
: undefined,
client_version: client.version,
entrypoint: originator,
sandbox,
Expand Down
39 changes: 39 additions & 0 deletions llp/0083-codex-live-cwd-from-rollout.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,45 @@ Codex now has the symmetric fallback.
- **One resolved `cwd`, used twice.** The same value feeds the `.hypignore` drop
and the row's stamped `cwd`, so live rows now carry the cwd the backfill reads
and the two halves of the policy agree (closes the live/backfill inconsistency).
- **A substituted workspace key never decides the verdict** (amended, #476). The
Codex projector picks a `workspaces` turn-metadata key for enrichment, falling
back to the *first* key when none matches the request's `cwd`. That substituted
key is a guess about a directory the session may never have run in, so it does
not supply the one resolved `cwd`: an explicit in-band `cwd` outranks it for
both the gate and the stamp, and the refusal is reported as
`plugin.codex.usage_policy_workspace_cwd_refused`
(`error_kind: workspace_cwd_mismatch`, paths hashed). The key keeps its
enrichment role (`attributes.codex.workspace`, `git_remote`, `git_commit`,
`has_changes`) and still supplies the `cwd` on the subscription route, where
the request states none and the key is the only in-band source there is.
Consequence: for a session running in a *subdirectory* of its workspace the
row now stamps the subdirectory rather than the workspace root, which is the
directory the policy is actually scoped to.
Three limits, stated rather than implied, and each one filed so it does not
live only here. The key still outranks the **rollout** fallback (it resolves
before the `??`), so a subscription-route session that declares a `workspaces`
map never consults `session_meta.cwd` and a first-key guess can still decide
its verdict (#480). That one is pre-existing, verified byte-identical before
and after this amendment; ranking the guess below the rollout is a separate
call, not taken in the lines PRs #467/#474 rewrite. Because the key keeps
enriching, a row recorded where it used to drop (clean in-band `cwd`, ignored
declared workspace) carries that workspace's identity even though the
directory it names is `.hypignore`-ignored: the gate is scoped by `cwd`
([LLP 0049](./0049-hypignore-usage-policy.spec.md#scope)), not by enrichment
source (#481). And the gate does not canonicalize paths, so *which spelling*
reaches it decides the verdict: a symlinked spelling of an ignored directory
escapes its `.hypignore`, because the ancestor walk climbs the symlink's own
parents and never meets the governing file. That is a property of the shared
matcher rather than of this amendment (#479, and it is also why `pathsEqual`
misses symlinked spellings here). What this amendment changes is which of two
symmetric spellings trips it, by taking the client's honest `cwd` over the
key's: it closes the case where the *key* held the non-canonical spelling and
opens the case where the *request* does. The widest case is untouched, a
declared symlinked key on a subscription-route request that states no `cwd` at
all, which leaks the same before and after. Canonicalizing belongs in the
shared matcher ([LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)),
where it must also canonicalize the `local-only` list entries or it un-governs
an entry a user marked by its symlink spelling.

## Why not the alternatives

Expand Down
177 changes: 177 additions & 0 deletions test/plugins/codex-exchange-projector.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,183 @@ test('Codex workspace selection prefers recorded cwd over first metadata key', (
assert.equal(projection.attributes.codex.git_origin_url, 'git@github.com:acme/actual.git')
})

// ---------------------------------------------------------------------
// A substituted workspace key must not decide a privacy verdict (#476)
//
// @ref LLP 0083#decision [tests]: `selectCodexWorkspace` falls back to the
// first `workspaces` key when none matches the request's cwd. That fallback is
// load-bearing on the subscription route (no in-band cwd at all), but when the
// request DOES state a cwd the substituted key is a guess about a directory the
// session never ran in, and it used to be the `.hypignore` gate's input.
// ---------------------------------------------------------------------

test('the .hypignore gate uses the request cwd, not a substituted workspace key (#476 case a)', () => {
// The leak: the session really ran in an IGNORED tree, but the only declared
// workspace is a clean one, so the verdict used to be computed for the clean
// tree and the opted-out exchange was recorded.
const projector = createCodexExchangeProjector({
resolver: ignoringResolver('/work/ignored'),
})
const projection = projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({
'x-codex-turn-metadata': JSON.stringify({
thread_id: 'thread-476a',
workspaces: { '/work/clean/proj': {} },
}),
}),
request_body: JSON.stringify({ cwd: '/work/ignored/real', input: 'secret' }),
response_body: JSON.stringify({ output_text: 'done' }),
}), context())
assert.equal(projection, USAGE_POLICY_DROP, 'the request cwd is ignored, so the exchange must drop')
})

test('an unrelated ignored workspace key does not drop a session it never covered (#476 case b)', () => {
// The mirror failure: the session ran in a clean tree, the only declared
// workspace is an ignored one, and the substitution used to force a drop.
const projector = createCodexExchangeProjector({
resolver: ignoringResolver('/work/ignored'),
})
const projection = /** @type {any} */ (projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({
'x-codex-turn-metadata': JSON.stringify({
thread_id: 'thread-476b',
workspaces: { '/work/ignored/proj': {} },
}),
}),
request_body: JSON.stringify({ cwd: '/work/clean/real', input: 'go' }),
response_body: JSON.stringify({ output_text: 'done' }),
}), context()))
assert.ok(projection && projection !== USAGE_POLICY_DROP, 'no .hypignore covers this session')
assert.equal(projection.cwd, '/work/clean/real', 'the row records where the session actually ran')
})

test('a refused workspace substitution is logged with hashed paths, not silently applied (#476 case c)', () => {
/** @type {Array<{ message: string, fields?: Record<string, unknown> }>} */
const warns = []
const projector = createCodexExchangeProjector({
resolver: ignoringResolver('/work/ignored'),
})
const log = {
debug() {},
info() {},
error() {},
/** @param {string} message @param {Record<string, unknown>=} fields */
warn: (message, fields) => { warns.push({ message, fields }) },
}
// A relative cwd cannot equal any absolute workspace key, so the
// substitution used to run AHEAD of any in-band cwd check and drop on the
// unrelated ignored key without a word in the log.
const projection = projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({
'x-codex-turn-metadata': JSON.stringify({
thread_id: 'thread-476c',
workspaces: { '/work/ignored/proj': {} },
}),
}),
request_body: JSON.stringify({ cwd: 'sub', input: 'go' }),
response_body: JSON.stringify({ output_text: 'done' }),
}), { log })
assert.ok(projection && projection !== USAGE_POLICY_DROP, 'the guessed workspace must not decide the verdict')
const refused = warns.find((e) => e.message === 'plugin.codex.usage_policy_workspace_cwd_refused')
assert.ok(refused, 'expected a usage_policy_workspace_cwd_refused warn')
assert.equal(refused.fields?.error_kind, 'workspace_cwd_mismatch')
assert.equal(refused.fields?.component, 'codex')
// This repo captures LLM traffic: the signal carries hashes, never raw paths.
assert.ok(
!JSON.stringify(refused.fields).includes('/work/ignored/proj'),
'the refused workspace path is hashed, never logged raw',
)
})

test('a refused workspace substitution still enriches the row from the workspace key (#476)', () => {
// The substitution keeps its ENRICHMENT role: only the gate/stamp cwd is
// taken back from it. Losing `workspace` / git identity would be a separate
// regression (LLP 0032#capture).
const projector = createCodexExchangeProjector({
resolver: ignoringResolver('/work/ignored'),
})
const projection = /** @type {any} */ (projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({
'x-codex-turn-metadata': JSON.stringify({
thread_id: 'thread-476d',
workspaces: {
'/work/clean/proj': {
associated_remote_urls: { origin: 'git@github.com:acme/clean.git' },
latest_git_commit_hash: 'deadbeef',
},
},
}),
}),
request_body: JSON.stringify({ cwd: '/work/clean/elsewhere', input: 'go' }),
response_body: JSON.stringify({ output_text: 'done' }),
}), context()))
assert.equal(projection.cwd, '/work/clean/elsewhere')
assert.equal(projection.attributes.codex.workspace, '/work/clean/proj')
assert.equal(projection.git_remote, 'git@github.com:acme/clean.git')
assert.equal(projection.head_sha, 'deadbeef')
})

test('the workspace key still supplies the gate cwd when the request states none (#476)', () => {
// The subscription route often carries no cwd at all, and then the workspace
// key is the ONLY in-band source of one. Refusing it outright would REMOVE
// real `.hypignore` coverage, so the fallback must survive this fix.
const projector = createCodexExchangeProjector({
resolver: ignoringResolver('/work/ignored'),
})
const projection = projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({
'x-codex-turn-metadata': JSON.stringify({
thread_id: 'thread-476e',
workspaces: { '/work/ignored/proj': {} },
}),
}),
request_body: JSON.stringify({ input: 'secret' }),
response_body: JSON.stringify({ output_text: 'done' }),
}), context())
assert.equal(projection, USAGE_POLICY_DROP, 'the workspace key is the only cwd there is, so it still gates')
})

test('no workspace-cwd refusal is logged when the key matches or the request states no cwd (#476)', () => {
// Guards the refusal predicate from the other side: nothing was substituted
// away, so nothing must be reported. Both negative branches at once - the
// key matching the request cwd, and no in-band cwd to contradict it.
/** @type {Array<{ message: string, fields?: Record<string, unknown> }>} */
const warns = []
const projector = createCodexExchangeProjector()
const log = {
debug() {},
info() {},
error() {},
/** @param {string} message @param {Record<string, unknown>=} fields */
warn: (message, fields) => { warns.push({ message, fields }) },
}
const turnMetadata = { thread_id: 'thread-476f', workspaces: { '/work/clean/proj': {} } }
for (const body of [{ cwd: '/work/clean/proj', input: 'go' }, { input: 'go' }]) {
projector.project(exchange({
path: '/backend-api/codex/responses',
provider: 'chatgpt',
request_headers: JSON.stringify({ 'x-codex-turn-metadata': JSON.stringify(turnMetadata) }),
request_body: JSON.stringify(body),
response_body: JSON.stringify({ output_text: 'done' }),
}), { log })
}
assert.deepEqual(
warns.filter((e) => e.message === 'plugin.codex.usage_policy_workspace_cwd_refused'),
[],
'an uncontradicted workspace key is not a refusal',
)
})

test('non-codex provider has no codex turn metadata but still stamps identity_source for symmetry', () => {
const projector = createCodexExchangeProjector()
const projection = /** @type {any} */ (projector.project(exchange({
Expand Down
Loading