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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "harness-everything",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
13
|
-
"version": "0.41.
|
|
13
|
+
"version": "0.41.3",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Miya Daniel"
|
|
16
16
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
3
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
4
|
-
"version": "0.41.
|
|
4
|
+
"version": "0.41.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.41.3](https://github.com/dyphn1/Harness-everything/compare/v0.41.2...v0.41.3) (2026-10-25)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **codex:** drop format-bound task approvals ([7125220](https://github.com/dyphn1/Harness-everything/commit/71252205e469cda13744a733ff2dca6bbff5db47))
|
|
10
|
+
|
|
5
11
|
## [0.41.2](https://github.com/dyphn1/Harness-everything/compare/v0.41.1...v0.41.2) (2026-10-25)
|
|
6
12
|
|
|
7
13
|
### Bug Fixes
|
package/ci/action-gate.test.js
CHANGED
|
@@ -273,6 +273,30 @@ try {
|
|
|
273
273
|
check(empty.degraded === false && empty.rules.length === 0, 'valid empty rule table does not silently activate built-in defaults');
|
|
274
274
|
check(actionGate.evaluatePreToolUse(payload('git push --force origin main', { toolUseId: 'empty' }), { host: 'claude', sessionDir, ruleTable: empty }).kind === 'allow', 'empty-table mutation control disables detection, proving production non-empty assertion is meaningful');
|
|
275
275
|
|
|
276
|
+
// Independent full-input fingerprint regression: an own JSON __proto__ key must
|
|
277
|
+
// remain part of the structured payload digest (null-prototype stableValue fix).
|
|
278
|
+
const protoInput = JSON.parse('{"__proto__":{"changed":true},"record_id":"record-123"}');
|
|
279
|
+
const protoPayload = {
|
|
280
|
+
session_id: 'phase5-test',
|
|
281
|
+
hook_event_name: 'PreToolUse',
|
|
282
|
+
tool_name: 'mcp__demo__delete_record',
|
|
283
|
+
tool_use_id: 'proto-fingerprint',
|
|
284
|
+
tool_input: protoInput,
|
|
285
|
+
cwd: ROOT,
|
|
286
|
+
};
|
|
287
|
+
const basePayload = {
|
|
288
|
+
session_id: 'phase5-test',
|
|
289
|
+
hook_event_name: 'PreToolUse',
|
|
290
|
+
tool_name: 'mcp__demo__delete_record',
|
|
291
|
+
tool_use_id: 'proto-fingerprint',
|
|
292
|
+
tool_input: { record_id: 'record-123' },
|
|
293
|
+
cwd: ROOT,
|
|
294
|
+
};
|
|
295
|
+
const protoText = actionGate.exactPayloadText(protoPayload);
|
|
296
|
+
const baseText = actionGate.exactPayloadText(basePayload);
|
|
297
|
+
check(protoText.includes('__proto__'), 'null-prototype serialization preserves an own __proto__ key');
|
|
298
|
+
check(actionGate.hashExact(protoText) !== actionGate.hashExact(baseText), 'own __proto__ key changes the full-input digest');
|
|
299
|
+
|
|
276
300
|
const execPayload = payload('npm publish', { toolUseId: 'exec-ok' });
|
|
277
301
|
actionGate.evaluatePreToolUse(execPayload, { host: 'claude', sessionDir, ruleTable: table });
|
|
278
302
|
const execResult = actionGate.processEvent({ ...execPayload, hook_event_name: 'PostToolUse' }, { sessionDir });
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// RED contract: Codex UserPromptSubmit must pass every prompt, including the
|
|
5
|
+
// former exact `HARNESS APPROVE <id>` spelling, to the normal router. The hook
|
|
6
|
+
// is transport, not a natural-language authorization parser.
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
14
|
+
const SOURCE_HOOK = path.join(ROOT, 'hooks', 'scripts', 'codex-user-prompt.js');
|
|
15
|
+
|
|
16
|
+
let failed = 0;
|
|
17
|
+
function check(condition, message) {
|
|
18
|
+
if (condition) console.log(` PASS ${message}`);
|
|
19
|
+
else { console.error(` FAIL ${message}`); failed++; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Dependency control: source hook must exist. Child Node is run with the
|
|
23
|
+
// executable that is already running this test.
|
|
24
|
+
check(fs.existsSync(SOURCE_HOOK), 'source codex-user-prompt.js exists');
|
|
25
|
+
|
|
26
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-codex-prompt-passthrough-'));
|
|
27
|
+
const fixtureRoot = path.join(tempRoot, 'fixture');
|
|
28
|
+
const hookDir = path.join(fixtureRoot, 'hooks', 'scripts');
|
|
29
|
+
const routerDir = path.join(fixtureRoot, 'harness-everything', 'scripts');
|
|
30
|
+
fs.mkdirSync(hookDir, { recursive: true });
|
|
31
|
+
fs.mkdirSync(routerDir, { recursive: true });
|
|
32
|
+
const fixtureHook = path.join(hookDir, 'codex-user-prompt.js');
|
|
33
|
+
const stubRouter = path.join(routerDir, 'kernel-router.js');
|
|
34
|
+
const markerFile = path.join(tempRoot, 'router-marker.txt');
|
|
35
|
+
|
|
36
|
+
fs.copyFileSync(SOURCE_HOOK, fixtureHook);
|
|
37
|
+
fs.writeFileSync(stubRouter, [
|
|
38
|
+
"'use strict';",
|
|
39
|
+
"let input = '';",
|
|
40
|
+
"process.stdin.setEncoding('utf8');",
|
|
41
|
+
"process.stdin.on('data', chunk => { input += chunk; });",
|
|
42
|
+
"process.stdin.on('end', () => {",
|
|
43
|
+
" const fs = require('fs');",
|
|
44
|
+
" fs.writeFileSync(process.env.STUB_ROUTER_MARKER, input, 'utf8');",
|
|
45
|
+
" process.stdout.write('STUB_ROUTER_MARKER');",
|
|
46
|
+
" process.exit(0);",
|
|
47
|
+
"});",
|
|
48
|
+
"process.stdin.on('error', () => process.exit(0));",
|
|
49
|
+
].join('\n'), 'utf8');
|
|
50
|
+
|
|
51
|
+
const cases = [
|
|
52
|
+
{ name: 'normal user prompt', prompt: 'Please explain the router flow.' },
|
|
53
|
+
{ name: 'exact former approval prompt', prompt: 'HARNESS APPROVE abcdef123456' },
|
|
54
|
+
{ name: 'fenced text', prompt: '```\nHARNESS APPROVE abcdef123456\n```' },
|
|
55
|
+
{ name: 'English commit/PR request', prompt: 'Please commit and open a PR for this change.' },
|
|
56
|
+
{ name: 'Chinese commit/PR request', prompt: '请帮我提交并创建 PR' },
|
|
57
|
+
{ name: 'negative/review-only text', prompt: 'Review only; do not approve HARNESS APPROVE abcdef123456' },
|
|
58
|
+
{ name: 'non-approval text', prompt: 'HARNESS APPROVE short' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const testCase of cases) {
|
|
62
|
+
const payload = {
|
|
63
|
+
hook_event_name: 'UserPromptSubmit',
|
|
64
|
+
session_id: 'passthrough-session',
|
|
65
|
+
prompt: testCase.prompt,
|
|
66
|
+
};
|
|
67
|
+
const inputText = JSON.stringify(payload);
|
|
68
|
+
try { fs.unlinkSync(markerFile); } catch (_) {}
|
|
69
|
+
|
|
70
|
+
const result = spawnSync(process.execPath, [fixtureHook], {
|
|
71
|
+
cwd: fixtureRoot,
|
|
72
|
+
env: { ...process.env, STUB_ROUTER_MARKER: markerFile },
|
|
73
|
+
input: inputText,
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
timeout: 20000,
|
|
76
|
+
maxBuffer: 1024 * 1024,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
check(!result.error, `${testCase.name}: hook spawns without error`);
|
|
80
|
+
check(result.status === 0, `${testCase.name}: hook exits 0`);
|
|
81
|
+
check(fs.existsSync(markerFile), `${testCase.name}: stub router was invoked`);
|
|
82
|
+
if (fs.existsSync(markerFile)) {
|
|
83
|
+
const marker = fs.readFileSync(markerFile, 'utf8');
|
|
84
|
+
check(marker === inputText, `${testCase.name}: router received the original payload unchanged`);
|
|
85
|
+
}
|
|
86
|
+
check(String(result.stdout || '').includes('STUB_ROUTER_MARKER'),
|
|
87
|
+
`${testCase.name}: router output is forwarded to the host`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Cleanup
|
|
91
|
+
try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch (_) {}
|
|
92
|
+
|
|
93
|
+
console.log(`\n${failed === 0 ? 'PASS' : 'FAIL'}: codex prompt pass-through (${failed} failure${failed === 1 ? '' : 's'})`);
|
|
94
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const actionGate = require(path.join(ROOT, 'hooks', 'scripts', 'action-gate.js'));
|
|
12
|
+
const PRE_HOOK = path.join(ROOT, 'hooks', 'scripts', 'codex-action-gate-pre.js');
|
|
13
|
+
|
|
14
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-task-git-auth-'));
|
|
15
|
+
const stateHome = path.join(tempRoot, 'state');
|
|
16
|
+
fs.mkdirSync(stateHome, { recursive: true });
|
|
17
|
+
|
|
18
|
+
const env = {
|
|
19
|
+
...process.env,
|
|
20
|
+
HARNESS_ACTION_GATE_HOST: 'codex',
|
|
21
|
+
HARNESS_STATE_HOME: stateHome,
|
|
22
|
+
HARNESS_ACTION_GATE_STDIN_TIMEOUT_MS: '2000',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function run(payload) {
|
|
26
|
+
const result = spawnSync(process.execPath, [PRE_HOOK], {
|
|
27
|
+
cwd: tempRoot,
|
|
28
|
+
env,
|
|
29
|
+
input: JSON.stringify(payload),
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
timeout: 20000,
|
|
32
|
+
maxBuffer: 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
assert.ifError(result.error);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function payload(toolName, toolInput, extra = {}) {
|
|
39
|
+
return {
|
|
40
|
+
hook_event_name: 'PreToolUse',
|
|
41
|
+
session_id: 'task-git-auth-codex-session',
|
|
42
|
+
turn_id: 'turn-task-git-auth',
|
|
43
|
+
tool_use_id: `call-${Math.random().toString(16).slice(2)}`,
|
|
44
|
+
tool_name: toolName,
|
|
45
|
+
tool_input: toolInput,
|
|
46
|
+
permission_mode: 'default',
|
|
47
|
+
cwd: tempRoot,
|
|
48
|
+
...extra,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const actualPrPayload = payload('mcp__codex_apps__github__create_pull_request', {
|
|
53
|
+
repository_full_name: 'openai/codex',
|
|
54
|
+
base: 'main',
|
|
55
|
+
head: 'feature/task-authorization',
|
|
56
|
+
title: 'Phase complete PR',
|
|
57
|
+
body: '## Summary\n\nPhase complete PR created after task authorization.',
|
|
58
|
+
draft: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
assert.ok(fs.existsSync(PRE_HOOK), 'Codex PreToolUse entrypoint must exist');
|
|
63
|
+
|
|
64
|
+
// Read control on the real Codex path.
|
|
65
|
+
const readResult = run(payload('mcp__demo__get_pull_request', { pull_request: 1 }));
|
|
66
|
+
assert.strictEqual(readResult.status, 0, 'read-only structured action must pass on Codex');
|
|
67
|
+
assert.strictEqual(String(readResult.stdout || '').trim(), '', 'read control must not emit a Harness permission decision');
|
|
68
|
+
|
|
69
|
+
// Dangerous controls remain hard Codex blocks.
|
|
70
|
+
for (const [toolName, toolInput] of [
|
|
71
|
+
['mcp__demo__merge_pull_request', { id: 'target' }],
|
|
72
|
+
['mcp__demo__delete_pull_request', { id: 'target' }],
|
|
73
|
+
['mcp__demo__deploy_release', { id: 'target' }],
|
|
74
|
+
['mcp__demo__publish_package', { id: 'target' }],
|
|
75
|
+
['mcp__demo__send_message', { id: 'target' }],
|
|
76
|
+
]) {
|
|
77
|
+
const result = run(payload(toolName, toolInput));
|
|
78
|
+
assert.strictEqual(result.status, 2, `${toolName} must remain a Codex block`);
|
|
79
|
+
}
|
|
80
|
+
const forcePush = run(payload('Bash', { command: 'git push --force origin main' }));
|
|
81
|
+
assert.strictEqual(forcePush.status, 2, 'git push --force must remain a Codex block');
|
|
82
|
+
|
|
83
|
+
// Normal non-destructive commit keeps its existing ungated behavior.
|
|
84
|
+
const normalCommit = run(payload('Bash', { command: 'git commit -m "phase complete"' }));
|
|
85
|
+
assert.strictEqual(normalCommit.status, 0, 'normal git commit must remain ungated on Codex');
|
|
86
|
+
|
|
87
|
+
// Misleading names containing create_pull_request must not inherit the exemption.
|
|
88
|
+
for (const toolName of [
|
|
89
|
+
'mcp__demo__create_pull_request_and_delete',
|
|
90
|
+
'mcp__demo__delete_create_pull_request',
|
|
91
|
+
'mcp__demo__create_pull_requests',
|
|
92
|
+
'mcp__demo__foo_create_pull_request',
|
|
93
|
+
]) {
|
|
94
|
+
const result = run(payload(toolName, { id: 'target' }));
|
|
95
|
+
assert.strictEqual(result.status, 2, `${toolName} must remain a Codex block`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Explicit required gate is carried in the supported payload hint shape
|
|
99
|
+
// (payload.workflowPlan.actionGate — the shape routerActionGateHint reads),
|
|
100
|
+
// not a made-up options API.
|
|
101
|
+
const requiredDecision = actionGate.evaluatePreToolUse({
|
|
102
|
+
...actualPrPayload,
|
|
103
|
+
tool_use_id: 'call-required-gate-witness',
|
|
104
|
+
workflowPlan: { actionGate: { required: true, reasonCodes: ['external-side-effect'], disposition: 'pending-approval' } },
|
|
105
|
+
}, {
|
|
106
|
+
host: 'codex',
|
|
107
|
+
ruleTable: actionGate.loadRuleTable(path.join(ROOT, 'hooks', 'scripts', 'action-gate-rules.json')),
|
|
108
|
+
});
|
|
109
|
+
assert.strictEqual(requiredDecision.kind, 'block', 'required-gate PR must remain gated');
|
|
110
|
+
assert.strictEqual(requiredDecision.exitCode, 2, 'required-gate PR must remain a Codex block');
|
|
111
|
+
assert.ok(requiredDecision.rule, 'required-gate PR must match a gated rule');
|
|
112
|
+
assert.strictEqual(requiredDecision.rule.id, 'structured-mutate', 'required-gate PR must keep structured-mutate');
|
|
113
|
+
|
|
114
|
+
// Recognized PR creation is a routine host-deferred operation. Its descriptor
|
|
115
|
+
// stays a mutation; it is not read or safe, and it must not use Harness consent.
|
|
116
|
+
const descriptor = actionGate.normalizeActionDescriptor(actualPrPayload);
|
|
117
|
+
assert.strictEqual(descriptor.action, 'mutate', 'recognized PR creation must remain classified as a mutation');
|
|
118
|
+
assert.notStrictEqual(descriptor.classification, 'safe', 'recognized PR creation must not be safe/read');
|
|
119
|
+
const prResult = run(actualPrPayload);
|
|
120
|
+
assert.strictEqual(prResult.status, 0, 'recognized PR creation must be host-deferred on Codex (currently exits 2: RED)');
|
|
121
|
+
assert.strictEqual(String(prResult.stdout || '').trim(), '', 'recognized PR creation must not emit permissionDecision allow/ask');
|
|
122
|
+
assert.ok(!/HARNESS APPROVE/i.test(`${prResult.stdout || ''}\n${prResult.stderr || ''}`), 'recognized PR creation must not require a Harness approval phrase');
|
|
123
|
+
|
|
124
|
+
// Provider-neutral exact create_pull_request operation, if used, has the same contract.
|
|
125
|
+
const neutralResult = run(payload('mcp__demo__create_pull_request', {
|
|
126
|
+
repository: 'acme/harness',
|
|
127
|
+
head: 'feature/task-authorization',
|
|
128
|
+
base: 'main',
|
|
129
|
+
title: 'Phase complete PR',
|
|
130
|
+
body: 'Provider-neutral PR body.',
|
|
131
|
+
}));
|
|
132
|
+
assert.strictEqual(neutralResult.status, 0, 'exact create_pull_request operation must be host-deferred on Codex (currently exits 2: RED)');
|
|
133
|
+
|
|
134
|
+
// Genuine mutations remain blocked, but the fixed-format approval protocol is retired.
|
|
135
|
+
const legacyMutation = run(payload('mcp__demo__update_issue', { issue_id: 1, title: 'changed' }));
|
|
136
|
+
assert.strictEqual(legacyMutation.status, 2, 'genuine mutation must remain blocked on Codex');
|
|
137
|
+
assert.ok(!/HARNESS APPROVE/i.test(`${legacyMutation.stdout || ''}\n${legacyMutation.stderr || ''}`), 'genuine mutation must not expose the retired Harness approval phrase');
|
|
138
|
+
|
|
139
|
+
console.log('PASS: task git authorization Codex controls and host-deferred PR creation');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(err && err.stack ? err.stack : err);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
} finally {
|
|
144
|
+
try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch (_) {}
|
|
145
|
+
}
|
|
@@ -125,24 +125,31 @@ Current Harness PreToolUse enforcement remains wired to the tool set explicitly
|
|
|
125
125
|
|
|
126
126
|
Related tracking: #82, #85, #98, #100, #109, #110.
|
|
127
127
|
|
|
128
|
-
##
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
128
|
+
## Prompt handling and authorization
|
|
129
|
+
|
|
130
|
+
Codex `UserPromptSubmit` prompts pass through to the normal router unchanged.
|
|
131
|
+
Harness does not parse, bind, or intercept any fixed approval syntax in user
|
|
132
|
+
prompts. Native agent task-scope authorization and native permissions remain
|
|
133
|
+
authoritative.
|
|
134
|
+
|
|
135
|
+
Routine exact `create_pull_request` operations are host-deferred (below). All
|
|
136
|
+
other risky actions (merge/delete/deploy/publish/send/force-push and unrelated
|
|
137
|
+
genuine mutations) remain hard PreToolUse blocks; an explicitly required
|
|
138
|
+
`actionGate` hint still gates. These are deterministic mechanism changes, not
|
|
139
|
+
live-host proof.
|
|
140
|
+
|
|
141
|
+
## Routine task Git operations (host-deferred)
|
|
142
|
+
|
|
143
|
+
Ordinary exact `create_pull_request` operations (including
|
|
144
|
+
`mcp__codex_apps__github__create_pull_request` and provider-neutral
|
|
145
|
+
`mcp__*__create_pull_request`) are host-deferred: Harness does not issue an
|
|
146
|
+
additional challenge merely because the generic `create` heuristic classifies
|
|
147
|
+
them as `mutate`. Their action descriptor stays `mutate` and non-safe; Harness
|
|
148
|
+
does not pretend a write is a read or emit `permissionDecision` allow/ask.
|
|
149
|
+
|
|
150
|
+
Native agent task-scope authorization and native permissions remain authoritative.
|
|
151
|
+
An explicitly supplied required `actionGate` hint still gates the call. All other
|
|
152
|
+
risky actions (merge/delete/deploy/publish/send/force-push and unrelated genuine
|
|
153
|
+
mutations) remain gated; exact operation matching is used, with no substring
|
|
154
|
+
exemption, so misleading names such as `create_pull_requests` or
|
|
155
|
+
`foo_create_pull_request` still block.
|
|
@@ -25,6 +25,15 @@ The repository routing contract uses **step-scoped workflow selection**. Normali
|
|
|
25
25
|
|
|
26
26
|
This is a **repository/agent contract**, not an automatic upgrade to any platform row below. A hook can prove that the contract was injected and record an agent's binding disposition; neither proves that a live host actually loaded or followed the referenced content. Instruction-only surfaces can carry the same rule without mechanically enforcing it. Promoting behavior to live-host compliance requires retained host/session evidence under the #82 compatibility program. Claude and local OpenAI packages carry workflow entry/completion **reminders** plus separate Rule-of-3/permission boundaries, but this package/mechanism change does not upgrade `platform-compatibility.json` live-host status values. See [workflow runtime](workflow-runtime.md) for scope, worktree isolation, and failure behavior. OpenCode and instruction-only/public Skills-only surfaces do not acquire equivalent runtime gating. Shell inspection is not a filesystem sandbox; absent hooks, agent-writable state, and indirect script effects remain limits.
|
|
27
27
|
|
|
28
|
+
### Action-gate mechanism vs. host semantic boundary
|
|
29
|
+
|
|
30
|
+
Routine exact `create_pull_request` operations are host-deferred at the mechanism level:
|
|
31
|
+
their descriptor remains a mutation (not read/safe), and no Harness consent or
|
|
32
|
+
`permissionDecision` is emitted. Native agent task-scope authorization and native
|
|
33
|
+
permissions stay authoritative; an explicit required `actionGate` hint still gates.
|
|
34
|
+
This is a deterministic mechanism change, not live-host proof, and does not
|
|
35
|
+
upgrade any platform row's live-host status.
|
|
36
|
+
|
|
28
37
|
## Current capability summary
|
|
29
38
|
|
|
30
39
|
| Platform surface | Standalone skills | Plugin support | Hooks/lifecycle | Project scope | Global scope | Current evidence boundary |
|
|
@@ -126,22 +135,17 @@ The compatibility path registers hook commands in Codex user configuration and c
|
|
|
126
135
|
|
|
127
136
|
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
137
|
|
|
129
|
-
### Codex
|
|
130
|
-
|
|
131
|
-
Codex
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
138
|
+
### Codex prompt pass-through and authorization (mechanism only)
|
|
139
|
+
|
|
140
|
+
Codex `UserPromptSubmit` prompts pass through to the normal router unchanged;
|
|
141
|
+
Harness does not parse or intercept a fixed approval syntax. Native agent
|
|
142
|
+
task-scope authorization and native permissions remain authoritative. Routine
|
|
143
|
+
exact `create_pull_request` operations are host-deferred at the mechanism level:
|
|
144
|
+
their descriptor remains a mutation (not read/safe), and no Harness consent or
|
|
145
|
+
`permissionDecision` is emitted. All other risky actions remain hard PreToolUse
|
|
146
|
+
blocks; an explicitly required `actionGate` hint still gates. These are
|
|
147
|
+
deterministic mechanism changes, not live-host proof. Codex ask-style decisions
|
|
148
|
+
are not supported.
|
|
145
149
|
|
|
146
150
|
## Install/uninstall ownership boundary
|
|
147
151
|
|
|
@@ -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 || '');
|
package/package.json
CHANGED