harness-everything 0.41.2 → 0.42.0

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.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/benchmarks/results/h1-provider-independence-qualification/README.md +72 -0
  5. package/ci/action-gate.test.js +24 -0
  6. package/ci/mechanism-325-3b-h1-qualification.test.js +206 -0
  7. package/ci/mechanism-341-opencode-lane.test.js +465 -0
  8. package/ci/mechanism-codex-prompt-pass-through.test.js +94 -0
  9. package/ci/mechanism-task-git-authorization.test.js +145 -0
  10. package/docs/codex-action-gate.md +28 -21
  11. package/docs/platform-capabilities.md +20 -16
  12. package/hooks/scripts/action-gate.js +18 -47
  13. package/hooks/scripts/codex-user-prompt.js +4 -16
  14. package/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
  15. package/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
  16. package/hooks/scripts/workflow-stop-gate.js +4 -1
  17. package/opencode-plugin/index.mjs +77 -5
  18. package/opencode-plugin/plugin.json +1 -1
  19. package/package.json +1 -1
  20. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  21. package/plugins/harness-everything/hooks/scripts/action-gate.js +18 -47
  22. package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +4 -16
  23. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
  24. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
  25. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +4 -1
  26. package/plugins/harness-everything/plugin.json +1 -1
  27. package/sbom.cdx.json +7 -7
  28. package/ci/mechanism-codex-action-authorization.test.js +0 -292
  29. package/hooks/scripts/lib/codex-action-authorization.js +0 -193
  30. package/plugins/harness-everything/hooks/scripts/lib/codex-action-authorization.js +0 -193
@@ -1,292 +0,0 @@
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
- });
@@ -1,193 +0,0 @@
1
- 'use strict';
2
-
3
- // Cooperative Codex explicit-authorization state. This is NOT tamperproof:
4
- // it is local hook state keyed by host session. A local process or user can
5
- // delete or rewrite it. It records consent only; it never bypasses Codex's
6
- // native permission controls.
7
- const crypto = require('crypto');
8
- const fs = require('fs');
9
- const path = require('path');
10
-
11
- const { getWorkspaceRoot, getSessionDir } = require('./harness-state');
12
-
13
- const DIR_NAME = 'codex-action-authorization';
14
- const CHALLENGES = 'challenges';
15
- const GRANTS = 'grants';
16
-
17
- function sessionDirOf(payload, options) {
18
- if (options && options.sessionDir) return options.sessionDir;
19
- const root = (options && options.root) || getWorkspaceRoot(payload);
20
- const sessionId = payload && (payload.session_id || payload.sessionId);
21
- return getSessionDir(root, sessionId, payload);
22
- }
23
-
24
- function safeToken(value) {
25
- return String(value || 'x').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 160) || 'x';
26
- }
27
-
28
- function authDir(payload, options, leaf) {
29
- return path.join(sessionDirOf(payload, options), DIR_NAME, leaf);
30
- }
31
-
32
- function challengesDir(payload, options) { return authDir(payload, options, CHALLENGES); }
33
- function grantsDir(payload, options) { return authDir(payload, options, GRANTS); }
34
-
35
- function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
36
-
37
- function readJson(file) {
38
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
39
- }
40
-
41
- function atomicWriteJson(filePath, value) {
42
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
- const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44
- fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
45
- fs.renameSync(temp, filePath);
46
- }
47
-
48
- function newChallengeId() {
49
- return `ch-${crypto.randomBytes(12).toString('hex')}`;
50
- }
51
-
52
- // Canonical challenge ids are minted by this helper as ch-<24 hex>. A pending
53
- // grant is stored as exactly <challengeId>.json. Anything with an extra suffix
54
- // (.consumed, .consumed.json, etc.) is a claim/consumed record, not pending.
55
- const CANONICAL_CHALLENGE_ID = /^ch-[0-9a-f]{24}$/;
56
- function isPendingGrantFileName(name) {
57
- if (typeof name !== 'string') return false;
58
- const match = name.match(/^([^/\\]+)\.json$/);
59
- if (!match) return false;
60
- return CANONICAL_CHALLENGE_ID.test(match[1]);
61
- }
62
-
63
- function toolNameOf(payload) {
64
- return String((payload && (payload.tool_name || payload.toolName)) || '');
65
- }
66
-
67
- function validIdentity(value) {
68
- return typeof value === 'string' && value.trim().length > 0;
69
- }
70
-
71
- function actionGateModule() {
72
- try { return require(path.join(__dirname, '..', 'action-gate.js')); } catch (_) { return null; }
73
- }
74
-
75
- function canonicalDigest(payload) {
76
- const ag = actionGateModule();
77
- if (ag && typeof ag.exactPayloadText === 'function' && typeof ag.hashExact === 'function') {
78
- return ag.hashExact(ag.exactPayloadText(payload));
79
- }
80
- const input = (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
81
- return crypto.createHash('sha256').update(JSON.stringify(input), 'utf8').digest('hex');
82
- }
83
-
84
- function issueChallenge(payload, options) {
85
- options = options || {};
86
- const challengeId = options.challengeId || newChallengeId();
87
- const dir = challengesDir(payload, options);
88
- ensureDir(dir);
89
- const file = path.join(dir, `${safeToken(challengeId)}.json`);
90
- const record = {
91
- schemaVersion: 1,
92
- challengeId,
93
- sessionId: payload && (payload.session_id || payload.sessionId) || null,
94
- toolName: options.toolName || toolNameOf(payload),
95
- digest: options.digest || canonicalDigest(payload),
96
- createdAt: new Date().toISOString(),
97
- };
98
- atomicWriteJson(file, record);
99
- return challengeId;
100
- }
101
-
102
- function bindConsent(payload, options) {
103
- options = options || {};
104
- const challengeId = options.challengeId;
105
- if (!challengeId) return { bound: false, reason: 'missing-challenge-id' };
106
-
107
- const dir = challengesDir(payload, options);
108
- const file = path.join(dir, `${safeToken(challengeId)}.json`);
109
- const challenge = readJson(file);
110
- if (!challenge) return { bound: false, reason: 'challenge-not-found' };
111
-
112
- const sessionId = payload && (payload.session_id || payload.sessionId);
113
- if (challenge.sessionId && challenge.sessionId !== sessionId) {
114
- return { bound: false, reason: 'session-mismatch' };
115
- }
116
-
117
- const consentTurn = payload && (payload.turn_id || payload.turnId);
118
- if (!validIdentity(sessionId) || !validIdentity(consentTurn)) {
119
- return { bound: false, reason: 'missing-identity' };
120
- }
121
- const gdir = grantsDir(payload, options);
122
- ensureDir(gdir);
123
- const grantFile = path.join(gdir, `${safeToken(challengeId)}.json`);
124
- const grant = {
125
- schemaVersion: 1,
126
- challengeId,
127
- sessionId,
128
- consentTurn: consentTurn || null,
129
- toolName: challenge.toolName,
130
- digest: challenge.digest,
131
- createdAt: new Date().toISOString(),
132
- consumed: false,
133
- };
134
- atomicWriteJson(grantFile, grant);
135
- return { bound: true, challenge };
136
- }
137
-
138
- function consumeGrantIfValid(payload, options) {
139
- options = options || {};
140
- const sessionId = payload && (payload.session_id || payload.sessionId);
141
- const turnId = payload && (payload.turn_id || payload.turnId);
142
- if (!validIdentity(sessionId) || !validIdentity(turnId)) {
143
- return { authorized: false };
144
- }
145
- const toolName = toolNameOf(payload);
146
- const digest = canonicalDigest(payload);
147
-
148
- const dir = grantsDir(payload, options);
149
- let files = [];
150
- try { files = fs.readdirSync(dir).filter(isPendingGrantFileName); } catch (_) { files = []; }
151
-
152
- for (const name of files) {
153
- const file = path.join(dir, name);
154
- const grant = readJson(file);
155
- if (!grant || grant.consumed) continue;
156
- if (grant.sessionId !== sessionId) continue;
157
- if (grant.consentTurn !== turnId) continue; // cross-turn / expired
158
- if (grant.toolName !== toolName) continue;
159
- if (grant.digest !== digest) continue;
160
-
161
- // Atomic consume: first rename wins.
162
- const consumedFile = path.join(dir, `${path.basename(name, '.json')}.consumed`);
163
- try {
164
- fs.renameSync(file, consumedFile);
165
- } catch (_) {
166
- continue; // lost race or already consumed
167
- }
168
- grant.consumed = true;
169
- grant.consumedAt = new Date().toISOString();
170
- try { atomicWriteJson(consumedFile, grant); } catch (_) { /* best effort */ }
171
- return { authorized: true };
172
- }
173
- return { authorized: false };
174
- }
175
-
176
- function isExactConsentPrompt(prompt) {
177
- return /^HARNESS APPROVE [A-Za-z0-9._-]{8,}$/.test(String(prompt || ''));
178
- }
179
-
180
- function parseConsentPrompt(prompt) {
181
- const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
182
- return match ? match[1] : null;
183
- }
184
-
185
- module.exports = {
186
- bindConsent,
187
- consumeGrantIfValid,
188
- issueChallenge,
189
- isExactConsentPrompt,
190
- parseConsentPrompt,
191
- canonicalDigest,
192
- toolNameOf,
193
- };
@@ -1,193 +0,0 @@
1
- 'use strict';
2
-
3
- // Cooperative Codex explicit-authorization state. This is NOT tamperproof:
4
- // it is local hook state keyed by host session. A local process or user can
5
- // delete or rewrite it. It records consent only; it never bypasses Codex's
6
- // native permission controls.
7
- const crypto = require('crypto');
8
- const fs = require('fs');
9
- const path = require('path');
10
-
11
- const { getWorkspaceRoot, getSessionDir } = require('./harness-state');
12
-
13
- const DIR_NAME = 'codex-action-authorization';
14
- const CHALLENGES = 'challenges';
15
- const GRANTS = 'grants';
16
-
17
- function sessionDirOf(payload, options) {
18
- if (options && options.sessionDir) return options.sessionDir;
19
- const root = (options && options.root) || getWorkspaceRoot(payload);
20
- const sessionId = payload && (payload.session_id || payload.sessionId);
21
- return getSessionDir(root, sessionId, payload);
22
- }
23
-
24
- function safeToken(value) {
25
- return String(value || 'x').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 160) || 'x';
26
- }
27
-
28
- function authDir(payload, options, leaf) {
29
- return path.join(sessionDirOf(payload, options), DIR_NAME, leaf);
30
- }
31
-
32
- function challengesDir(payload, options) { return authDir(payload, options, CHALLENGES); }
33
- function grantsDir(payload, options) { return authDir(payload, options, GRANTS); }
34
-
35
- function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
36
-
37
- function readJson(file) {
38
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
39
- }
40
-
41
- function atomicWriteJson(filePath, value) {
42
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
- const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44
- fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
45
- fs.renameSync(temp, filePath);
46
- }
47
-
48
- function newChallengeId() {
49
- return `ch-${crypto.randomBytes(12).toString('hex')}`;
50
- }
51
-
52
- // Canonical challenge ids are minted by this helper as ch-<24 hex>. A pending
53
- // grant is stored as exactly <challengeId>.json. Anything with an extra suffix
54
- // (.consumed, .consumed.json, etc.) is a claim/consumed record, not pending.
55
- const CANONICAL_CHALLENGE_ID = /^ch-[0-9a-f]{24}$/;
56
- function isPendingGrantFileName(name) {
57
- if (typeof name !== 'string') return false;
58
- const match = name.match(/^([^/\\]+)\.json$/);
59
- if (!match) return false;
60
- return CANONICAL_CHALLENGE_ID.test(match[1]);
61
- }
62
-
63
- function toolNameOf(payload) {
64
- return String((payload && (payload.tool_name || payload.toolName)) || '');
65
- }
66
-
67
- function validIdentity(value) {
68
- return typeof value === 'string' && value.trim().length > 0;
69
- }
70
-
71
- function actionGateModule() {
72
- try { return require(path.join(__dirname, '..', 'action-gate.js')); } catch (_) { return null; }
73
- }
74
-
75
- function canonicalDigest(payload) {
76
- const ag = actionGateModule();
77
- if (ag && typeof ag.exactPayloadText === 'function' && typeof ag.hashExact === 'function') {
78
- return ag.hashExact(ag.exactPayloadText(payload));
79
- }
80
- const input = (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
81
- return crypto.createHash('sha256').update(JSON.stringify(input), 'utf8').digest('hex');
82
- }
83
-
84
- function issueChallenge(payload, options) {
85
- options = options || {};
86
- const challengeId = options.challengeId || newChallengeId();
87
- const dir = challengesDir(payload, options);
88
- ensureDir(dir);
89
- const file = path.join(dir, `${safeToken(challengeId)}.json`);
90
- const record = {
91
- schemaVersion: 1,
92
- challengeId,
93
- sessionId: payload && (payload.session_id || payload.sessionId) || null,
94
- toolName: options.toolName || toolNameOf(payload),
95
- digest: options.digest || canonicalDigest(payload),
96
- createdAt: new Date().toISOString(),
97
- };
98
- atomicWriteJson(file, record);
99
- return challengeId;
100
- }
101
-
102
- function bindConsent(payload, options) {
103
- options = options || {};
104
- const challengeId = options.challengeId;
105
- if (!challengeId) return { bound: false, reason: 'missing-challenge-id' };
106
-
107
- const dir = challengesDir(payload, options);
108
- const file = path.join(dir, `${safeToken(challengeId)}.json`);
109
- const challenge = readJson(file);
110
- if (!challenge) return { bound: false, reason: 'challenge-not-found' };
111
-
112
- const sessionId = payload && (payload.session_id || payload.sessionId);
113
- if (challenge.sessionId && challenge.sessionId !== sessionId) {
114
- return { bound: false, reason: 'session-mismatch' };
115
- }
116
-
117
- const consentTurn = payload && (payload.turn_id || payload.turnId);
118
- if (!validIdentity(sessionId) || !validIdentity(consentTurn)) {
119
- return { bound: false, reason: 'missing-identity' };
120
- }
121
- const gdir = grantsDir(payload, options);
122
- ensureDir(gdir);
123
- const grantFile = path.join(gdir, `${safeToken(challengeId)}.json`);
124
- const grant = {
125
- schemaVersion: 1,
126
- challengeId,
127
- sessionId,
128
- consentTurn: consentTurn || null,
129
- toolName: challenge.toolName,
130
- digest: challenge.digest,
131
- createdAt: new Date().toISOString(),
132
- consumed: false,
133
- };
134
- atomicWriteJson(grantFile, grant);
135
- return { bound: true, challenge };
136
- }
137
-
138
- function consumeGrantIfValid(payload, options) {
139
- options = options || {};
140
- const sessionId = payload && (payload.session_id || payload.sessionId);
141
- const turnId = payload && (payload.turn_id || payload.turnId);
142
- if (!validIdentity(sessionId) || !validIdentity(turnId)) {
143
- return { authorized: false };
144
- }
145
- const toolName = toolNameOf(payload);
146
- const digest = canonicalDigest(payload);
147
-
148
- const dir = grantsDir(payload, options);
149
- let files = [];
150
- try { files = fs.readdirSync(dir).filter(isPendingGrantFileName); } catch (_) { files = []; }
151
-
152
- for (const name of files) {
153
- const file = path.join(dir, name);
154
- const grant = readJson(file);
155
- if (!grant || grant.consumed) continue;
156
- if (grant.sessionId !== sessionId) continue;
157
- if (grant.consentTurn !== turnId) continue; // cross-turn / expired
158
- if (grant.toolName !== toolName) continue;
159
- if (grant.digest !== digest) continue;
160
-
161
- // Atomic consume: first rename wins.
162
- const consumedFile = path.join(dir, `${path.basename(name, '.json')}.consumed`);
163
- try {
164
- fs.renameSync(file, consumedFile);
165
- } catch (_) {
166
- continue; // lost race or already consumed
167
- }
168
- grant.consumed = true;
169
- grant.consumedAt = new Date().toISOString();
170
- try { atomicWriteJson(consumedFile, grant); } catch (_) { /* best effort */ }
171
- return { authorized: true };
172
- }
173
- return { authorized: false };
174
- }
175
-
176
- function isExactConsentPrompt(prompt) {
177
- return /^HARNESS APPROVE [A-Za-z0-9._-]{8,}$/.test(String(prompt || ''));
178
- }
179
-
180
- function parseConsentPrompt(prompt) {
181
- const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
182
- return match ? match[1] : null;
183
- }
184
-
185
- module.exports = {
186
- bindConsent,
187
- consumeGrantIfValid,
188
- issueChallenge,
189
- isExactConsentPrompt,
190
- parseConsentPrompt,
191
- canonicalDigest,
192
- toolNameOf,
193
- };