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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +2 -2
- package/CHANGELOG.md +14 -0
- package/ci/mechanism-365-step-edit-accounting.test.js +395 -0
- package/ci/mechanism-codex-action-authorization.test.js +292 -0
- package/docs/codex-action-gate.md +22 -0
- package/docs/doc-workflow-reconciliation.md +13 -13
- package/docs/document-lifecycle.md +7 -5
- package/docs/platform-capabilities.md +17 -0
- package/hooks/scripts/action-gate.js +53 -3
- package/hooks/scripts/codex-user-prompt.js +20 -0
- package/hooks/scripts/lib/codex-action-authorization.js +193 -0
- package/hooks/scripts/state-persist.js +27 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/action-gate.js +53 -3
- package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +20 -0
- package/plugins/harness-everything/hooks/scripts/lib/codex-action-authorization.js +193 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +27 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
|
@@ -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:
|
|
9
|
-
|
|
10
|
-
This document refines the [document lifecycle contract](document-lifecycle.md#phase-3b--narrow-integration-contract-s1-
|
|
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
|
|
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).
|
|
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
|
|
343
|
-
negative-credit slice. S2
|
|
344
|
-
positive reconciliation and freshness
|
|
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.
|
|
1084
|
-
|
|
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
|
|
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
|
|
1204
|
-
- Not claimed: cryptographic attestation, a sandbox, a forced review provider, default-ON, live-host behaviour beyond the
|
|
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
|
|
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 | **
|
|
21
|
-
| 4 — derived-view generators and `describes:` drift | **
|
|
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
|
-
|
|
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
|
|
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 || '');
|