diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 496e77b99..4872902a6 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -74,7 +74,10 @@ export function createCodexExchangeProjector(opts = {}) { /** * @param {AiGatewayExchangeInput} input * @param {{ - * log?: { info?: (m: string, f?: Record) => void }, + * log?: { + * info?: (m: string, f?: Record) => void, + * warn?: (m: string, f?: Record) => void, + * }, * isSessionIgnored?: (sessionId: string) => boolean, * }} [ctx] */ @@ -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') { @@ -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 @@ -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, diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index cd5c31436..4b778010c 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -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 diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index f08ea6cd2..a85460910 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -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 }>} */ + const warns = [] + const projector = createCodexExchangeProjector({ + resolver: ignoringResolver('/work/ignored'), + }) + const log = { + debug() {}, + info() {}, + error() {}, + /** @param {string} message @param {Record=} 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 }>} */ + const warns = [] + const projector = createCodexExchangeProjector() + const log = { + debug() {}, + info() {}, + error() {}, + /** @param {string} message @param {Record=} 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({