harness-everything 0.41.0 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // End-to-end contract for scoped Codex consent: a blocked structured mutation
5
+ // exposes a pending challenge; only a host-submitted exact challenge response
6
+ // may authorize that same input once in the consent turn.
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawn, spawnSync } = require('child_process');
12
+
13
+ const ROOT = path.resolve(__dirname, '..');
14
+ const actionGate = require(path.join(ROOT, 'hooks/scripts/action-gate.js'));
15
+ const PRE_HOOK = path.join(ROOT, 'hooks/scripts/codex-action-gate-pre.js');
16
+ const PROMPT_HOOK = path.join(ROOT, 'hooks/scripts/codex-user-prompt.js');
17
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-codex-auth-'));
18
+ const workspace = path.join(tempRoot, 'workspace');
19
+ const stateHome = path.join(tempRoot, 'state');
20
+ fs.mkdirSync(workspace, { recursive: true });
21
+
22
+ const env = {
23
+ ...process.env,
24
+ HARNESS_ACTION_GATE_HOST: 'codex',
25
+ HARNESS_STATE_HOME: stateHome,
26
+ HARNESS_WORKSPACE_ROOT: workspace,
27
+ HARNESS_ACTION_GATE_STDIN_TIMEOUT_MS: '2000',
28
+ };
29
+
30
+ function runHook(script, payload) {
31
+ const result = spawnSync(process.execPath, [script], {
32
+ cwd: workspace,
33
+ env,
34
+ input: JSON.stringify(payload),
35
+ encoding: 'utf8',
36
+ timeout: 20000,
37
+ maxBuffer: 1024 * 1024,
38
+ });
39
+ assert.ifError(result.error);
40
+ return result;
41
+ }
42
+
43
+ function runHookAsync(script, payload) {
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn(process.execPath, [script], { cwd: workspace, env, stdio: ['pipe', 'pipe', 'pipe'] });
46
+ let stdout = '';
47
+ let stderr = '';
48
+ child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
49
+ child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
50
+ child.on('error', reject);
51
+ child.on('close', status => resolve({ status, stdout, stderr }));
52
+ child.stdin.end(JSON.stringify(payload));
53
+ });
54
+ }
55
+
56
+ function toolPayload({ sessionId = 'codex-auth-session', turnId = 'turn-before-consent', toolUseId = 'call-1', title = 'Create draft PR' } = {}) {
57
+ return {
58
+ hook_event_name: 'PreToolUse',
59
+ session_id: sessionId,
60
+ turn_id: turnId,
61
+ tool_use_id: toolUseId,
62
+ tool_name: 'mcp__demo__create_pull_request',
63
+ tool_input: {
64
+ repository: 'acme/harness',
65
+ head: 'codex/fix-codex-authorization',
66
+ base: 'main',
67
+ title,
68
+ body: 'Draft description for the authorized change.',
69
+ },
70
+ permission_mode: 'default',
71
+ cwd: workspace,
72
+ };
73
+ }
74
+
75
+ function userConsent(sessionId, turnId, challengeId) {
76
+ return {
77
+ hook_event_name: 'UserPromptSubmit',
78
+ session_id: sessionId,
79
+ turn_id: turnId,
80
+ prompt: `HARNESS APPROVE ${challengeId}`,
81
+ cwd: workspace,
82
+ };
83
+ }
84
+
85
+ function challengeIdFrom(result) {
86
+ const match = `${result.stdout || ''}\n${result.stderr || ''}`
87
+ .match(/(?:challenge(?:\s+id)?[:# ]+)([A-Za-z0-9._-]{8,})/i);
88
+ return match && match[1];
89
+ }
90
+
91
+ function authorizationDirectoryFor(sessionId) {
92
+ const { getWorkspaceKey } = require(path.join(ROOT, 'scripts/lib/workspace'));
93
+ return path.join(stateHome, 'workspaces', getWorkspaceKey(workspace), 'state', 'sessions',
94
+ sessionId, 'codex-action-authorization');
95
+ }
96
+
97
+ function grantFileFor(sessionId, challengeId) {
98
+ return path.join(authorizationDirectoryFor(sessionId), 'grants', `${challengeId}.json`);
99
+ }
100
+
101
+ async function main() {
102
+ try {
103
+ assert.ok(fs.existsSync(PRE_HOOK), 'Codex PreToolUse entrypoint is present');
104
+ assert.ok(fs.existsSync(PROMPT_HOOK), 'Codex UserPromptSubmit entrypoint is present');
105
+
106
+ // Positive control: a read-only structured action remains ungated.
107
+ const safeRead = runHook(PRE_HOOK, {
108
+ ...toolPayload({ toolUseId: 'read-control' }),
109
+ tool_name: 'mcp__demo__get_pull_request',
110
+ });
111
+ assert.strictEqual(safeRead.status, 0, 'read control should pass');
112
+
113
+ // Shell rules remain governed by the existing action gate, not this scoped
114
+ // structured-mutation consent flow.
115
+ const shellSession = 'codex-auth-shell-session';
116
+ const shellAction = toolPayload({ sessionId: shellSession, turnId: 'turn-before-consent' });
117
+ shellAction.tool_name = 'Bash';
118
+ shellAction.tool_input = { command: 'git push --force origin main' };
119
+ const shellBlock = runHook(PRE_HOOK, shellAction);
120
+ const shellChallengeId = challengeIdFrom(shellBlock);
121
+ const shellChallengeDir = path.join(authorizationDirectoryFor(shellSession), 'challenges');
122
+ const shellChallengeCount = fs.existsSync(shellChallengeDir)
123
+ ? fs.readdirSync(shellChallengeDir).filter(name => name.endsWith('.json')).length
124
+ : 0;
125
+ const shellConsent = runHook(PROMPT_HOOK, userConsent(
126
+ shellSession,
127
+ 'turn-consent',
128
+ shellChallengeId || 'ch-000000000000000000000000',
129
+ ));
130
+ const shellRetry = runHook(PRE_HOOK, {
131
+ ...shellAction,
132
+ turn_id: 'turn-consent',
133
+ tool_use_id: 'call-shell-retry',
134
+ });
135
+ assert.deepStrictEqual({
136
+ blocked: shellBlock.status,
137
+ challengeExposed: Boolean(shellChallengeId),
138
+ challengesStored: shellChallengeCount,
139
+ retry: shellRetry.status,
140
+ consentHandled: shellConsent.status,
141
+ }, {
142
+ blocked: 2,
143
+ challengeExposed: false,
144
+ challengesStored: 0,
145
+ retry: 2,
146
+ consentHandled: 0,
147
+ }, 'Codex shell-governed actions must block without entering scoped mutation consent');
148
+
149
+ // An own JSON __proto__ property is part of the tool input and must change
150
+ // the digest; it must not disappear during stable serialization.
151
+ const protoSession = 'codex-auth-proto-session';
152
+ const approvedAction = toolPayload({ sessionId: protoSession, turnId: 'turn-before-consent' });
153
+ const protoBlock = runHook(PRE_HOOK, approvedAction);
154
+ assert.strictEqual(protoBlock.status, 2, 'unapproved prototype-input action must block');
155
+ const protoChallengeId = challengeIdFrom(protoBlock);
156
+ assert.ok(protoChallengeId, 'prototype-input action should expose a challenge id');
157
+ const protoConsent = runHook(PROMPT_HOOK, userConsent(protoSession, 'turn-consent', protoChallengeId));
158
+ assert.strictEqual(protoConsent.status, 0, 'prototype-input consent should be processed');
159
+ const alteredInput = JSON.parse(JSON.stringify(approvedAction.tool_input));
160
+ Object.defineProperty(alteredInput, '__proto__', { value: { changed: true }, enumerable: true });
161
+ const alteredProtoRetry = runHook(PRE_HOOK, {
162
+ ...approvedAction,
163
+ turn_id: 'turn-consent',
164
+ tool_use_id: 'call-altered-proto-input',
165
+ tool_input: alteredInput,
166
+ });
167
+ assert.strictEqual(alteredProtoRetry.status, 2, 'adding an own __proto__ input field must invalidate the approved digest');
168
+
169
+ // Missing host identity must not bind consent to the workspace default
170
+ // session bucket. Missing turns likewise cannot form a usable grant.
171
+ const missingSessionAction = toolPayload({ sessionId: null, turnId: 'turn-before-consent' });
172
+ const missingSessionBlock = runHook(PRE_HOOK, missingSessionAction);
173
+ assert.strictEqual(missingSessionBlock.status, 2, 'missing-session action must initially block');
174
+ const missingSessionChallenge = challengeIdFrom(missingSessionBlock);
175
+ assert.ok(missingSessionChallenge, 'missing-session block should expose its challenge');
176
+ const missingSessionConsent = runHook(PROMPT_HOOK, userConsent(null, 'turn-consent', missingSessionChallenge));
177
+ assert.strictEqual(missingSessionConsent.status, 0, 'missing-session consent event should be handled');
178
+ const missingSessionRetry = runHook(PRE_HOOK, toolPayload({
179
+ sessionId: null,
180
+ turnId: 'turn-consent',
181
+ toolUseId: 'call-missing-session',
182
+ }));
183
+ assert.strictEqual(missingSessionRetry.status, 2, 'missing session_id must not authorize a retry');
184
+
185
+ const missingTurnSession = 'codex-auth-missing-turn-session';
186
+ const missingTurnAction = toolPayload({ sessionId: missingTurnSession });
187
+ delete missingTurnAction.turn_id;
188
+ const missingTurnBlock = runHook(PRE_HOOK, missingTurnAction);
189
+ assert.strictEqual(missingTurnBlock.status, 2, 'missing-turn action must initially block');
190
+ const missingTurnChallenge = challengeIdFrom(missingTurnBlock);
191
+ assert.ok(missingTurnChallenge, 'missing-turn block should expose its challenge');
192
+ const missingTurnConsent = runHook(PROMPT_HOOK, userConsent(missingTurnSession, undefined, missingTurnChallenge));
193
+ assert.strictEqual(missingTurnConsent.status, 0, 'missing-turn consent event should be handled');
194
+ const missingTurnRetry = runHook(PRE_HOOK, {
195
+ ...missingTurnAction,
196
+ tool_use_id: 'call-missing-turn',
197
+ });
198
+ assert.strictEqual(missingTurnRetry.status, 2, 'missing turn_id must not authorize a retry');
199
+
200
+ // A grant file is claimed before its contents are rewritten. Both the
201
+ // current extensionless marker and the earlier JSON marker must stay denied.
202
+ for (const [claimedSession, marker] of [
203
+ ['codex-auth-claimed-session', '.consumed'],
204
+ ['codex-auth-legacy-claimed-session', '.consumed.json'],
205
+ ]) {
206
+ const claimedBlock = runHook(PRE_HOOK, toolPayload({ sessionId: claimedSession, turnId: 'turn-before-consent' }));
207
+ assert.strictEqual(claimedBlock.status, 2, 'unapproved claim-window fixture must block');
208
+ const claimedChallengeId = challengeIdFrom(claimedBlock);
209
+ assert.ok(claimedChallengeId, 'claim-window fixture should expose a challenge id');
210
+ const claimedConsent = runHook(PROMPT_HOOK, userConsent(claimedSession, 'turn-consent', claimedChallengeId));
211
+ assert.strictEqual(claimedConsent.status, 0, 'claim-window fixture consent should be processed');
212
+ const grantFile = grantFileFor(claimedSession, claimedChallengeId);
213
+ const claimedFile = path.join(path.dirname(grantFile), `${claimedChallengeId}${marker}`);
214
+ assert.ok(fs.existsSync(grantFile), 'consent should create the available grant file');
215
+ fs.renameSync(grantFile, claimedFile);
216
+ const claimedRetry = runHook(PRE_HOOK, toolPayload({
217
+ sessionId: claimedSession,
218
+ turnId: 'turn-consent',
219
+ toolUseId: `call-after-claim-window-${marker.replace(/\W/g, '')}`,
220
+ }));
221
+ assert.strictEqual(claimedRetry.status, 2, `a ${marker} claim marker must not authorize a retry before consumed=true is persisted`);
222
+ }
223
+
224
+ // Baseline witness: an unauthorised mutation must block before consent.
225
+ const sessionId = 'codex-auth-session';
226
+ const blocked = runHook(PRE_HOOK, toolPayload({ sessionId }));
227
+ assert.strictEqual(blocked.status, 2, 'unapproved structured mutation must fail closed');
228
+
229
+ // The blocked call must expose a challenge that the host can bind to a
230
+ // later explicit consent event.
231
+ const challengeMatch = `${blocked.stdout || ''}\n${blocked.stderr || ''}`
232
+ .match(/(?:challenge(?:\s+id)?[:# ]+)([A-Za-z0-9._-]{8,})/i);
233
+ assert.ok(challengeMatch, 'blocked Codex structured mutation should expose a pending challenge id');
234
+ const challengeId = challengeMatch[1];
235
+ const blockText = String(blocked.stderr || '');
236
+ const expectedDigest = actionGate.normalizeActionDescriptor(toolPayload({ sessionId })).payloadHash;
237
+ assert.ok(blockText.includes('mcp__demo__create_pull_request'), 'block output should identify the approved tool');
238
+ assert.ok(blockText.includes(expectedDigest), 'block output should show the full-input digest');
239
+ assert.ok(blockText.includes(`HARNESS APPROVE ${challengeId}`), 'block output should show the exact approval prompt');
240
+ assert.ok(!blockText.includes('Draft description for the authorized change.'), 'block output should not expose raw tool input');
241
+
242
+ // A prompt that names the challenge is the only consent source. The real
243
+ // UserPromptSubmit entrypoint must bind it to host session + consent turn.
244
+ // Wrong session, changed full input, and an agent-supplied authorization
245
+ // claim are negative controls. None may spend or widen the pending grant.
246
+ const wrongSessionConsent = runHook(PROMPT_HOOK, userConsent('other-session', 'turn-consent', challengeId));
247
+ assert.strictEqual(wrongSessionConsent.status, 0, 'wrong-session prompt event should be handled without turning into a host error');
248
+ const wrongSession = runHook(PRE_HOOK, toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'wrong-session' }));
249
+ assert.strictEqual(wrongSession.status, 2, 'a challenge cannot be approved from another session');
250
+
251
+ const accepted = runHook(PROMPT_HOOK, userConsent(sessionId, 'turn-consent', challengeId));
252
+ assert.strictEqual(accepted.status, 0, 'exact consent prompt should be processed by UserPromptSubmit');
253
+ const changedInput = runHook(PRE_HOOK, toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'changed-input', title: 'Different PR' }));
254
+ assert.strictEqual(changedInput.status, 2, 'grant must not authorize a changed full tool input');
255
+ const selfAsserted = toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'self-asserted' });
256
+ selfAsserted.tool_input.authorization = { approved: true, scope: '*' };
257
+ const forgedClaim = runHook(PRE_HOOK, selfAsserted);
258
+ assert.strictEqual(forgedClaim.status, 2, 'tool-input authorization JSON must never be a trusted grant');
259
+
260
+ // The matching tool + full input is allowed once in the consent turn without
261
+ // a hook-level allow decision that could override Codex's native permissions.
262
+ const concurrentRetries = await Promise.all([
263
+ runHookAsync(PRE_HOOK, toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'call-retry-a' })),
264
+ runHookAsync(PRE_HOOK, toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'call-retry-b' })),
265
+ ]);
266
+ assert.deepStrictEqual(concurrentRetries.map(result => result.status).sort(), [0, 2], 'atomic consume must authorize exactly one concurrent exact retry');
267
+ const allowedRetry = concurrentRetries.find(result => result.status === 0);
268
+ assert.strictEqual(allowedRetry.stdout, '', 'scoped grant must not emit a Codex permissionDecision=allow');
269
+ const replay = runHook(PRE_HOOK, toolPayload({ sessionId, turnId: 'turn-consent', toolUseId: 'call-replay' }));
270
+ assert.strictEqual(replay.status, 2, 'a consumed grant must not authorize a replay');
271
+
272
+ // A separate grant that is not used in its consent turn expires at the next turn.
273
+ const laterAction = toolPayload({ sessionId, turnId: 'turn-before-consent-2', toolUseId: 'call-2', title: 'Create second draft PR' });
274
+ const blockedAgain = runHook(PRE_HOOK, laterAction);
275
+ const secondChallenge = `${blockedAgain.stdout || ''}\n${blockedAgain.stderr || ''}`
276
+ .match(/(?:challenge(?:\s+id)?[:# ]+)([A-Za-z0-9._-]{8,})/i);
277
+ assert.ok(secondChallenge, 'second blocked action should expose its own pending challenge');
278
+ const acceptedButUnused = runHook(PROMPT_HOOK, userConsent(sessionId, 'turn-consent-2', secondChallenge[1]));
279
+ assert.strictEqual(acceptedButUnused.status, 0);
280
+ const nextBlocked = runHook(PRE_HOOK, { ...laterAction, turn_id: 'turn-later', tool_use_id: 'call-later' });
281
+ assert.strictEqual(nextBlocked.status, 2, 'an unused grant must not survive its consent turn');
282
+
283
+ console.log('PASS: Codex structured authorization challenge is exact, scoped, and one-use.');
284
+ } finally {
285
+ fs.rmSync(tempRoot, { recursive: true, force: true });
286
+ }
287
+ }
288
+
289
+ main().catch(err => {
290
+ console.error(err);
291
+ process.exitCode = 1;
292
+ });
@@ -124,3 +124,25 @@ Current Harness PreToolUse enforcement remains wired to the tool set explicitly
124
124
  - Codex configuration reference: https://developers.openai.com/codex/config-reference
125
125
 
126
126
  Related tracking: #82, #85, #98, #100, #109, #110.
127
+
128
+ ## Codex explicit authorization (scoped consent)
129
+
130
+ In addition to the hard block above, a blocked Codex structured mutation exposes a
131
+ pending challenge scoped to the host session, tool name, and canonical full
132
+ tool-input digest. The pending challenge has no wall-clock TTL. The challenge id
133
+ appears in the block output as `challenge id: <id>`. Only scoped structured mutations (`rule.id` `structured-mutate`)
134
+ issue or consume grants; other actions keep ordinary blocking.
135
+
136
+ The only approval source is a real `UserPromptSubmit` hook receiving the exact
137
+ entire prompt `HARNESS APPROVE <challengeId>` with no additional text. That
138
+ event binds the host session and its consent turn to a one-use grant for the
139
+ same session, consent turn, tool, and full-input digest.
140
+
141
+ A matching retry passes once and atomically consumes the grant. Missing,
142
+ mismatched, cross-session, cross-turn, and replayed grants remain blocked. The
143
+ one-use grant is bound to the consent turn and fails after a turn change. Tool
144
+ input containing an `authorization` JSON claim never grants.
145
+
146
+ On a matching retry the guard does not block and emits no `permissionDecision=allow`;
147
+ Codex native permission controls remain authoritative. This is cooperative local
148
+ hook state, not tamperproof, and carries no live-host proof.
@@ -5,13 +5,13 @@ status: active
5
5
 
6
6
  # Document workflow reconciliation — Phase 3b S1 runtime
7
7
 
8
- **Status: S1 runtime ships default OFF; opt in with `HARNESS_DOC_RECONCILIATION_PILOT=1`.
9
- S1 grants no positive document verification credit. S2/S3 are pending; E1 has not started.**
10
- This document refines the [document lifecycle contract](document-lifecycle.md#phase-3b--narrow-integration-contract-s1-shipped-default-off-positive-reconciliation-not-shipped).
8
+ **Status: the runtime ships default OFF; opt in with `HARNESS_DOC_RECONCILIATION_PILOT=1`.
9
+ With the pilot ON, F4 ([#358](https://github.com/dyphn1/Harness-everything/pull/358)) grants limited positive credit for exactly `doc-lint` and `link-check`, from a host-captured run of the installed doc-check on Claude Code 2.1.309 (live runs 4–5). S3 provider integration is pending; E1 has not started.**
10
+ This document refines the [document lifecycle contract](document-lifecycle.md#phase-3b--narrow-integration-contract-s1-default-off-f4-limited-credit).
11
11
  The [accepted E0](https://github.com/dyphn1/Harness-everything/issues/341#issuecomment-6308604128)
12
12
  permits this limited, reversible consumer under the
13
13
  [owner's E0/E1 decision](https://github.com/dyphn1/Harness-everything/issues/325#issuecomment-6283237637).
14
- Current evidence establishes deterministic mechanism/package behavior, not live-host loading or E1 GO.
14
+ Current evidence establishes deterministic mechanism/package behavior and bounded Claude Code 2.1.309 live credit runs; it is not E1 GO.
15
15
 
16
16
  ## Entry gates and ownership
17
17
 
@@ -336,12 +336,12 @@ Preparation assertion rows do not carry `reportOrigin`; the S1 runtime report ca
336
336
  marker is never presented as a production report.
337
337
  ## S1 — switch, trusted scope and fail-closed no-credit gate
338
338
 
339
- **S1 runtime ships default OFF (`HARNESS_DOC_RECONCILIATION_PILOT=1` opts in). It grants no positive document verification credit; S2/S3 are pending and E1 has not started.**
339
+ **S1 runtime ships default OFF (`HARNESS_DOC_RECONCILIATION_PILOT=1` opts in). With the pilot ON, F4 (#358) grants limited positive credit for `doc-lint` and `link-check` only; S3 provider integration is pending and E1 has not started.**
340
340
  The [accepted E0](https://github.com/dyphn1/Harness-everything/issues/341#issuecomment-6308604128)
341
341
  permits this limited implementation; the [posted execution plan](https://github.com/dyphn1/Harness-everything/issues/325)
342
- orders Docs → RED → GREEN → independent review → green CI for each slice. S1 is a temporary
343
- negative-credit slice. S2 supplies trusted producer records, independent review provenance,
344
- positive reconciliation and freshness; only then can E1 observation begin. S3 adds optional
342
+ orders Docs → RED → GREEN → independent review → green CI for each slice. S1 was the temporary
343
+ negative-credit slice; F4 added the limited positive credit. The S2 requirements (trusted producer records, independent review provenance,
344
+ positive reconciliation and freshness) gate E1 observation. S3 adds optional
345
345
  provider suggestions. None of these slices authorizes wider #320 integration.
346
346
 
347
347
  ### Switch and scope decision
@@ -1080,8 +1080,8 @@ provider, discovery execution, public checker change or new attestation service/
1080
1080
  ## Implementation and acceptance after E0
1081
1081
 
1082
1082
  S1 ships the default-OFF switch, narrow disposition/completion no-credit integration and runtime
1083
- status reporting. S2's trusted producer, positive reconciliation and freshness remain pending;
1084
- S3's optional provider adapter remains pending. Require every expected applicable MUST and
1083
+ status reporting. F4 adds limited positive reconciliation behind the same switch. S3's optional
1084
+ provider adapter remains pending. Require every expected applicable MUST and
1085
1085
  freshness check before positive credit, plus source/package compatibility, negative cases and
1086
1086
  relevant green CI including Windows. The S1 suites establish their tested mechanism/package
1087
1087
  contracts; they do not establish independent host provenance, live-host loading or architectural
@@ -1090,7 +1090,7 @@ remain separate gates.
1090
1090
 
1091
1091
  ## F4: credited reconciliation (Claude-only, pilot ON)
1092
1092
 
1093
- Status: **implemented behind the pilot switch; mechanism and package evidence, plus one live field-availability capture.** Credit works for `doc-lint` and `link-check` (a registered `doc-check` run) and for an independent `quality-review` (a host reviewer with bound path digests). One live capture (Claude Code 2.1.308, macOS, one session) proves field availability, not live-host credit. There is no live-host credit run yet. Deterministic mechanism tests prove the chain. Design source: the A3 advisor report (`.worktrees/packets/feat-path/A3-advisor-report.md`, a local coordination file, not a shipped doc).
1093
+ Status: **implemented behind the pilot switch; mechanism and package evidence, plus live credit runs on Claude Code 2.1.309 (runs 4–5), which credited exactly `doc-lint` and `link-check`.** Mechanism tests cover credit for `doc-lint` and `link-check` (a registered `doc-check` run) and for an independent `quality-review` (a host reviewer with bound path digests). The earlier Claude Code 2.1.308 capture (macOS, one session) proves field availability only. Deterministic mechanism tests prove the chain. Design source: the A3 advisor report (`.worktrees/packets/feat-path/A3-advisor-report.md`, a local coordination file, not a shipped doc).
1094
1094
 
1095
1095
  ### Whole path
1096
1096
 
@@ -1200,8 +1200,8 @@ claude -p "<fixed capture prompt, containing the exact check command>" \
1200
1200
  ### Scope and limits
1201
1201
 
1202
1202
  - The first positive scope is a body-only edit to an existing governed reference, with no derived declaration, code, test, PRD or ADR normative change. Its applicable MUSTs are lint and links.
1203
- - Simulated replay and one live field capture are not E1 GO, and they do not permit a wider rollout.
1204
- - Not claimed: cryptographic attestation, a sandbox, a forced review provider, default-ON, live-host behaviour beyond the capture, or support for OpenCode or Codex.
1203
+ - Simulated replay and the recorded live credit runs are not E1 GO, and they do not permit a wider rollout.
1204
+ - Not claimed: cryptographic attestation, a sandbox, a forced review provider, default-ON, live-host behaviour beyond the recorded runs, or support for OpenCode or Codex.
1205
1205
 
1206
1206
  **Executor rationale.** The owner's text (#325, 2026-10-21) says the reviewer "must differ from the author's `session_id` and from every executor principal". An executor principal is the principal of execution evidence used in this assessment. A reviewer that read or computed digests with plain tool calls is not an executor. A reviewer whose registered doc-check run is refused still counts, which keeps the rule fail-closed.
1207
1207
 
@@ -15,19 +15,21 @@ This table distinguishes merged mechanism code from end-to-end workflow integrat
15
15
  | 2 — roles, IDs, aliases, legacy, supersession, index, D6.9 PRD revisions | **Merged** in #327; do not rebuild this lifecycle logic for Phase 3 |
16
16
  | Step-type prerequisite (#326) | **Complete:** PR #328 merged; Harness-owned `doc-change` is explicitly declared, has no implicit TDD binding, and is not auto-inserted by router/System One |
17
17
  | 3a — document facts, obligations and evidence producer | **Merged** in #334: `harness-everything/scripts/doc-obligations.js` and `doc-evidence.js`. H3/H4 have *partial deterministic mechanism* coverage, not integrated workflow evidence. Known limitations: caller-attested envelope; `configDigest` presence-only; `candidateDigest` restricted to `docs/prd/`; missing independent task-scope binding. Deleted and old rename paths are bound to trusted per-path baseline evidence ([doc-evidence-baseline-binding.md](doc-evidence-baseline-binding.md), #335 fixed by #342), with workflow-disposition reconciliation left out of scope |
18
- | 3b — pilot-local provider suggestions and workflow evidence reconciliation | **S1 runtime ships default OFF** (opt in with `HARNESS_DOC_RECONCILIATION_PILOT=1`), with trusted scope assessment and fail-closed withholding; **no positive document verification credit**. See [S1 switch, trusted scope and no-credit contract](doc-workflow-reconciliation.md#s1--switch-trusted-scope-and-fail-closed-no-credit-gate). **S3 pure advisory provider suggestions shipped in 0.37.0 (not wired); S2 trusted producer/positive reconciliation/freshness is pending; E1 has not started.** Accepted #341 E0 permits this limited reversible consumer. Current evidence is deterministic mechanism/package coverage, not live-host loading or E1 GO; preserve existing required-binding compatibility |
18
+ | 3b — pilot-local provider suggestions and workflow evidence reconciliation | **S1 runtime ships default OFF** (opt in with `HARNESS_DOC_RECONCILIATION_PILOT=1`), with trusted scope assessment and fail-closed withholding. **F4 (#358) adds limited positive credit** for exactly `doc-lint` and `link-check` from a host-captured run of the installed doc-check (Claude Code only; live runs on 2.1.309). See [S1 switch, trusted scope and no-credit contract](doc-workflow-reconciliation.md#s1--switch-trusted-scope-and-fail-closed-no-credit-gate). S3 pure advisory provider suggestions shipped in 0.37.0 (not wired); E1 has not started. Accepted #341 E0 permits this limited reversible consumer; the live runs are not E1 GO; preserve existing required-binding compatibility |
19
19
  | 3c — H1–H4 and paired-task evaluation | **Not started.** Mechanism tests alone are insufficient for architecture promotion; compare frozen baseline and treatment on real bounded tasks, success/failure first, then conditional cost and latency |
20
- | 1b — shared documentation craft and family metadata | **Splittable.** Shared references and thin skill consumers can proceed independently. A small optional logical-family metadata seam may be piloted; physical skill directory moves remain blocked on #320 identity/path separation |
21
- | 4 — derived-view generators and `describes:` drift | **Not started.** Preserve generator authorization, deterministic regenerate-equals and scoped reminder semantics |
20
+ | 1b — shared documentation craft and family metadata | **Shared craft delivered** in F2 (#356): shared references with thin skill consumers. Family metadata is not established. A small optional logical-family metadata seam may be piloted; physical skill directory moves remain blocked on #320 identity/path separation |
21
+ | 4 — derived-view generators and `describes:` drift | **Regenerate-equals delivered** in F3 (#357) for authorized generators; `describes:` drift reminders and broader maintenance are not established. Preserve generator authorization, deterministic regenerate-equals and scoped reminder semantics |
22
22
 
23
- ### Phase 3b — narrow integration contract (S1 shipped default OFF; positive reconciliation not shipped)
23
+ <a id="phase-3b--narrow-integration-contract-s1-shipped-default-off-positive-reconciliation-not-shipped"></a>
24
+
25
+ ### Phase 3b — narrow integration contract (S1 default OFF; F4 limited credit)
24
26
 
25
27
  The preparation design, test-only provider/trust contracts, and qualified RED replay are complete:
26
28
  [Document workflow reconciliation](doc-workflow-reconciliation.md),
27
29
  [case corpus](../ci/fixtures/doc-obligations/reconciliation-prep/cases.json),
28
30
  [pending-RED registry](../ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json), and
29
31
  [preparation suite](../ci/mechanism-325-3b-preparation.test.js). #341 E0 is accepted; S1 ships the
30
- default-OFF no-credit gate only. Positive reconciliation (S2) remains unshipped.
32
+ default-OFF gate, and F4 (#358) adds limited positive credit for `doc-lint` and `link-check` behind the same switch. S3 provider integration remains pending.
31
33
 
32
34
  - **Inputs:** explicit active `doc-change` step, D6.5 diff facts, Phase 3a obligations/evidence, available provider *metadata*, and host capabilities.
33
35
  - **Entry and activation gates (#341):** parallel preparatory work — provider metadata, adapter/interface design and RED fixtures — may proceed while #341 Phase 0A–0C runs, but the **runtime** Evidence Reconciliation must not merge or activate until **#341 E0** is reviewed and accepted (initial evidence read/binding/outcome conformance, mutation and read-order compatibility, source/package parity, zero false MUST PASS on applicable adversarial cases, and applicable green CI including Windows). After E0, only the **bounded, opt-in, reversible** documentation consumer is eligible for runtime integration at the existing verification/disposition path (no second router, forced provider, new lock, or weakened authorization, with a documented rollback), and it supplies the observed exposure for **#341 E1** (initial target 2–3 genuine development/review cycles with sufficient case mix, not elapsed time). **#341 E1 GO / REVISE / NO-GO gates expansion beyond the limited pilot, not the pilot's own observation** (missing or unrepresentative exposure is `UNCHECKED`, never an automatic GO). The H1–H4 paired-task architectural gate below stays required for any #320 promotion in addition to E1. Shared evidence-read/binding/decision defects route to #341 (fixture-first correction, regression replay, re-observation); documentation policy and the trusted host/VCS producer remain #325 deliverables here.
@@ -126,6 +126,23 @@ The compatibility path registers hook commands in Codex user configuration and c
126
126
 
127
127
  The public OpenAI Skills-only plugin is narrower again. Its ZIP contains the packaged `skills/` tree and referenced files, but not the local `.codex-plugin` lifecycle hooks. Public submission readiness is therefore a package/form contract, not evidence of public-directory approval or live hook execution.
128
128
 
129
+ ### Codex explicit UserPromptSubmit consent (mechanism only)
130
+
131
+ Codex has a local `UserPromptSubmit` exact-challenge consent path for a blocked
132
+ structured mutation. The blocked call exposes a pending challenge scoped to host
133
+ session, tool name, and canonical full tool-input digest. The pending challenge has
134
+ no wall-clock TTL. Only a real
135
+ `UserPromptSubmit` hook receiving the exact entire prompt
136
+ `HARNESS APPROVE <challengeId>` with no additional text can bind that challenge to
137
+ a host session and consent turn. The resulting one-use grant only lets the
138
+ matching retry pass Harness's guard; it does not emit `permissionDecision=allow`
139
+ and does not supersede native Codex permissions. Missing, mismatched, cross-session, cross-turn, and replayed grants remain
140
+ blocked. The one-use grant is bound to the consent turn and fails after a turn
141
+ change. Tool-input
142
+ authorization JSON never grants. This is cooperative local hook state: it is not
143
+ tamperproof and carries no live-host verification. Codex ask-style decisions are
144
+ not supported.
145
+
129
146
  ## Install/uninstall ownership boundary
130
147
 
131
148
  The general installer merges shared files only through Harness-owned markers and removes only manifest-tracked artifacts. Malformed configuration and pre-existing user-owned files fail closed. Install/uninstall round-trip coverage protects user skills and runs on Linux, Windows, and macOS fixtures.
@@ -9,6 +9,7 @@ const {
9
9
  getWorkspaceRoot,
10
10
  getSessionDir,
11
11
  } = require('./lib/harness-state');
12
+ const codexActionAuthorization = require('./lib/codex-action-authorization');
12
13
 
13
14
  const RULES_PATH = process.env.HARNESS_ACTION_GATE_RULES_PATH || path.join(__dirname, 'action-gate-rules.json');
14
15
  const ASK_CAPABLE_HOSTS = new Set(['claude']);
@@ -52,10 +53,12 @@ function toolInputOf(payload) {
52
53
  function stableValue(value) {
53
54
  if (Array.isArray(value)) return value.map(stableValue);
54
55
  if (!value || typeof value !== 'object') return value;
56
+ // Use a null-prototype accumulator so an own `__proto__` key is stored as data
57
+ // instead of mutating the accumulator's prototype (which would drop it).
55
58
  return Object.keys(value).sort().reduce((acc, key) => {
56
59
  acc[key] = stableValue(value[key]);
57
60
  return acc;
58
- }, {});
61
+ }, Object.create(null));
59
62
  }
60
63
 
61
64
  function exactPayloadText(payload) {
@@ -445,11 +448,45 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
445
448
  return record;
446
449
  }
447
450
 
451
+
452
+ function isScopedCodexStructuredMutation(structured, rule) {
453
+ return Boolean(
454
+ rule && rule.id === 'structured-mutate' &&
455
+ structured && structured.descriptor &&
456
+ structured.descriptor.toolFamily !== 'shell' &&
457
+ structured.descriptor.action === 'mutate' &&
458
+ structured.descriptor.classification === 'gated'
459
+ );
460
+ }
461
+
462
+ function safeInline(value) {
463
+ return String(value == null ? '' : value).replace(/[\r\n\t\u0000-\u001f\u007f]/g, '?').slice(0, 200);
464
+ }
465
+
448
466
  function evaluatePreToolUse(payload, options = {}) {
449
467
  const context = sessionContext(payload, options.sessionDir);
450
468
  const table = options.ruleTable || loadRuleTable(options.rulePath || RULES_PATH);
451
469
  const structured = structuredRule(payload);
452
470
  const rule = structured.rule || classify(payload, table.rules, context.sessionDir);
471
+ const host = detectHost(payload, options.host);
472
+
473
+ // Codex explicit authorization applies only to scoped structured mutations.
474
+ // Shell-governed and other structured actions keep ordinary blocking.
475
+ if (host === 'codex' && isScopedCodexStructuredMutation(structured, rule)) {
476
+ const consumed = codexActionAuthorization.consumeGrantIfValid(payload, { sessionDir: context.sessionDir });
477
+ if (consumed && consumed.authorized) {
478
+ return {
479
+ kind: 'allow',
480
+ exitCode: 0,
481
+ stdout: null,
482
+ stderr: null,
483
+ rule: null,
484
+ table,
485
+ descriptor: structured.descriptor,
486
+ };
487
+ }
488
+ }
489
+
453
490
  if (!rule) {
454
491
  return {
455
492
  kind: 'allow',
@@ -462,7 +499,6 @@ function evaluatePreToolUse(payload, options = {}) {
462
499
  };
463
500
  }
464
501
 
465
- const host = detectHost(payload, options.host);
466
502
  const reason = decisionReason(rule, table);
467
503
  if (hostSupportsAsk(host) && gatePolicy(options) === ALWAYS_ASK_POLICY) {
468
504
  const record = recordDecision(payload, rule, table, host, 'pending-approval', { ...options, sessionDir: context.sessionDir });
@@ -478,11 +514,25 @@ function evaluatePreToolUse(payload, options = {}) {
478
514
  const record = recordDecision(payload, rule, table, host, 'rejected', { ...options, sessionDir: context.sessionDir }, {
479
515
  rejectionReason: 'host-ask-decision-unverified-or-unsupported',
480
516
  });
517
+ let challengeSuffix = '';
518
+ let detailsSuffix = '';
519
+ if (host === 'codex' && isScopedCodexStructuredMutation(structured, rule)) {
520
+ const challengeId = codexActionAuthorization.issueChallenge(payload, {
521
+ sessionDir: context.sessionDir,
522
+ toolName: toolNameOf(payload),
523
+ digest: (structured.descriptor && structured.descriptor.payloadHash) || hashExact(exactPayloadText(payload)),
524
+ });
525
+ if (challengeId) {
526
+ challengeSuffix = ` challenge id: ${challengeId}`;
527
+ const digest = (structured.descriptor && structured.descriptor.payloadHash) || hashExact(exactPayloadText(payload));
528
+ detailsSuffix = ` Tool: ${safeInline(toolNameOf(payload))}. Input SHA-256: ${digest}. Approve with: HARNESS APPROVE ${challengeId}.`;
529
+ }
530
+ }
481
531
  return {
482
532
  kind: 'block',
483
533
  exitCode: 2,
484
534
  stdout: null,
485
- stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked.`,
535
+ stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked.${challengeSuffix}${detailsSuffix}`,
486
536
  rule,
487
537
  table,
488
538
  record,
@@ -22,6 +22,26 @@ function finish() {
22
22
  if (finished) return;
23
23
  finished = true;
24
24
  clearTimeout(timer);
25
+
26
+ let payload = null;
27
+ try { payload = JSON.parse(input || '{}'); } catch (_) { payload = null; }
28
+
29
+ // Codex explicit authorization: only an exact host-submitted consent prompt
30
+ // may bind a pending challenge to the host session + consent turn. No
31
+ // natural-language inference, no tool-input claim.
32
+ if (payload && payload.hook_event_name === 'UserPromptSubmit') {
33
+ const prompt = payload.prompt;
34
+ const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
35
+ if (match) {
36
+ try {
37
+ const auth = require('./lib/codex-action-authorization');
38
+ auth.bindConsent(payload, { challengeId: match[1] });
39
+ } catch (_) { /* consent state is best-effort; failures do not authorize */ }
40
+ process.exit(0);
41
+ return;
42
+ }
43
+ }
44
+
25
45
  if (!ROUTER) process.exit(0);
26
46
  const result = spawnSync(process.execPath, [ROUTER], { input, encoding: 'utf8', env: process.env, timeout: 25000 });
27
47
  const stderr = String(result.stderr || '');