harness-everything 0.41.2 → 0.41.3
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/CHANGELOG.md +6 -0
- package/ci/action-gate.test.js +24 -0
- package/ci/mechanism-codex-prompt-pass-through.test.js +94 -0
- package/ci/mechanism-task-git-authorization.test.js +145 -0
- package/docs/codex-action-gate.md +28 -21
- package/docs/platform-capabilities.md +20 -16
- package/hooks/scripts/action-gate.js +18 -47
- package/hooks/scripts/codex-user-prompt.js +4 -16
- 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 +18 -47
- package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +4 -16
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
- package/ci/mechanism-codex-action-authorization.test.js +0 -292
- package/hooks/scripts/lib/codex-action-authorization.js +0 -193
- package/plugins/harness-everything/hooks/scripts/lib/codex-action-authorization.js +0 -193
|
@@ -9,8 +9,6 @@ const {
|
|
|
9
9
|
getWorkspaceRoot,
|
|
10
10
|
getSessionDir,
|
|
11
11
|
} = require('./lib/harness-state');
|
|
12
|
-
const codexActionAuthorization = require('./lib/codex-action-authorization');
|
|
13
|
-
|
|
14
12
|
const RULES_PATH = process.env.HARNESS_ACTION_GATE_RULES_PATH || path.join(__dirname, 'action-gate-rules.json');
|
|
15
13
|
const ASK_CAPABLE_HOSTS = new Set(['claude']);
|
|
16
14
|
const VALID_SCOPES = new Set(['always', 'outside-scratch']);
|
|
@@ -135,10 +133,25 @@ function normalizeActionDescriptor(payload) {
|
|
|
135
133
|
};
|
|
136
134
|
}
|
|
137
135
|
|
|
136
|
+
function isRoutinePullRequestCreation(toolName) {
|
|
137
|
+
const name = String(toolName || '');
|
|
138
|
+
const segments = name.split('__');
|
|
139
|
+
const last = segments[segments.length - 1];
|
|
140
|
+
return (last === 'create_pull_request' || name === 'create_pull_request');
|
|
141
|
+
}
|
|
142
|
+
|
|
138
143
|
function structuredRule(payload) {
|
|
139
144
|
const descriptor = normalizeActionDescriptor(payload);
|
|
140
145
|
if (descriptor.toolFamily === 'shell') return { descriptor, rule: null };
|
|
141
146
|
if (descriptor.classification === 'safe') return { descriptor, rule: null };
|
|
147
|
+
// Routine PR creation is a host-deferred mutation, not a Harness-gated one:
|
|
148
|
+
// the generic create=>mutate heuristic must not add an extra challenge. The
|
|
149
|
+
// descriptor stays a mutation (not read/safe). An explicit required
|
|
150
|
+
// actionGate hint still gates it.
|
|
151
|
+
const hint = routerActionGateHint(payload);
|
|
152
|
+
if (isRoutinePullRequestCreation(descriptor.toolName) && !(hint && hint.required)) {
|
|
153
|
+
return { descriptor, rule: null };
|
|
154
|
+
}
|
|
142
155
|
if (descriptor.classification === 'gated') {
|
|
143
156
|
return {
|
|
144
157
|
descriptor,
|
|
@@ -148,7 +161,6 @@ function structuredRule(payload) {
|
|
|
148
161
|
},
|
|
149
162
|
};
|
|
150
163
|
}
|
|
151
|
-
const hint = routerActionGateHint(payload);
|
|
152
164
|
if (hint && hint.required) {
|
|
153
165
|
return {
|
|
154
166
|
descriptor,
|
|
@@ -449,19 +461,8 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
|
|
|
449
461
|
}
|
|
450
462
|
|
|
451
463
|
|
|
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
464
|
|
|
462
|
-
|
|
463
|
-
return String(value == null ? '' : value).replace(/[\r\n\t\u0000-\u001f\u007f]/g, '?').slice(0, 200);
|
|
464
|
-
}
|
|
465
|
+
|
|
465
466
|
|
|
466
467
|
function evaluatePreToolUse(payload, options = {}) {
|
|
467
468
|
const context = sessionContext(payload, options.sessionDir);
|
|
@@ -470,23 +471,6 @@ function evaluatePreToolUse(payload, options = {}) {
|
|
|
470
471
|
const rule = structured.rule || classify(payload, table.rules, context.sessionDir);
|
|
471
472
|
const host = detectHost(payload, options.host);
|
|
472
473
|
|
|
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
|
-
|
|
490
474
|
if (!rule) {
|
|
491
475
|
return {
|
|
492
476
|
kind: 'allow',
|
|
@@ -514,25 +498,11 @@ function evaluatePreToolUse(payload, options = {}) {
|
|
|
514
498
|
const record = recordDecision(payload, rule, table, host, 'rejected', { ...options, sessionDir: context.sessionDir }, {
|
|
515
499
|
rejectionReason: 'host-ask-decision-unverified-or-unsupported',
|
|
516
500
|
});
|
|
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
|
-
}
|
|
531
501
|
return {
|
|
532
502
|
kind: 'block',
|
|
533
503
|
exitCode: 2,
|
|
534
504
|
stdout: null,
|
|
535
|
-
stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked
|
|
505
|
+
stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked.`,
|
|
536
506
|
rule,
|
|
537
507
|
table,
|
|
538
508
|
record,
|
|
@@ -705,4 +675,5 @@ module.exports = {
|
|
|
705
675
|
validateRuleTable,
|
|
706
676
|
normalizeActionDescriptor,
|
|
707
677
|
structuredRule,
|
|
678
|
+
isRoutinePullRequestCreation,
|
|
708
679
|
};
|
|
@@ -26,22 +26,10 @@ function finish() {
|
|
|
26
26
|
let payload = null;
|
|
27
27
|
try { payload = JSON.parse(input || '{}'); } catch (_) { payload = null; }
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
29
|
+
// All prompts, including any legacy approval spelling, pass through unchanged
|
|
30
|
+
// to the normal router. Harness does not parse or intercept authorization
|
|
31
|
+
// syntax in user prompts; native agent/task scope and native permissions
|
|
32
|
+
// remain authoritative.
|
|
45
33
|
if (!ROUTER) process.exit(0);
|
|
46
34
|
const result = spawnSync(process.execPath, [ROUTER], { input, encoding: 'utf8', env: process.env, timeout: 25000 });
|
|
47
35
|
const stderr = String(result.stderr || '');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "harness-everything",
|
|
4
|
-
"version": "0.41.
|
|
4
|
+
"version": "0.41.3",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|
package/sbom.cdx.json
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
"$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.7",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:e4948d5f-cae4-5b4e-84bc-19997b97c420",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-10-
|
|
8
|
+
"timestamp": "2026-10-25T06:53:03.342Z",
|
|
9
9
|
"tools": [
|
|
10
10
|
{
|
|
11
11
|
"vendor": "Harness Everything",
|
|
12
12
|
"name": "generate-sbom.js",
|
|
13
|
-
"version": "0.41.
|
|
13
|
+
"version": "0.41.3"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"component": {
|
|
17
17
|
"type": "application",
|
|
18
|
-
"bom-ref": "pkg:npm/harness-everything@0.41.
|
|
18
|
+
"bom-ref": "pkg:npm/harness-everything@0.41.3",
|
|
19
19
|
"name": "harness-everything",
|
|
20
|
-
"version": "0.41.
|
|
21
|
-
"purl": "pkg:npm/harness-everything@0.41.
|
|
20
|
+
"version": "0.41.3",
|
|
21
|
+
"purl": "pkg:npm/harness-everything@0.41.3"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"components": [
|
|
@@ -2033,7 +2033,7 @@
|
|
|
2033
2033
|
]
|
|
2034
2034
|
},
|
|
2035
2035
|
{
|
|
2036
|
-
"ref": "pkg:npm/harness-everything@0.41.
|
|
2036
|
+
"ref": "pkg:npm/harness-everything@0.41.3",
|
|
2037
2037
|
"dependsOn": [
|
|
2038
2038
|
"pkg:npm/%40commitlint/cli@19.8.1",
|
|
2039
2039
|
"pkg:npm/%40commitlint/config-conventional@19.8.1",
|
|
@@ -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
|
-
});
|