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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/benchmarks/results/h1-provider-independence-qualification/README.md +72 -0
- package/ci/action-gate.test.js +24 -0
- package/ci/mechanism-325-3b-h1-qualification.test.js +206 -0
- package/ci/mechanism-341-opencode-lane.test.js +465 -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/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
- package/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
- package/hooks/scripts/workflow-stop-gate.js +4 -1
- package/opencode-plugin/index.mjs +77 -5
- 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/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +4 -1
- 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
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// qualification object ({ source: 'fake-test-fixture', faked: true }) is a
|
|
16
16
|
// test-only construct for mechanism tests; production passes no
|
|
17
17
|
// qualification and stays gated.
|
|
18
|
-
// -
|
|
19
|
-
//
|
|
18
|
+
// - The Claude provider accepts only Claude. OpenCode has a separate
|
|
19
|
+
// fail-closed mapping and never borrows Claude's exit or reviewer rules.
|
|
20
20
|
// - Exit mapping (owner decision row 1): for a given `tool_use_id`, a captured
|
|
21
21
|
// `PostToolUse` with `interrupted === false` and no `PostToolUseFailure`
|
|
22
22
|
// arrival maps to exit 0. This is a named mapping of the host's own
|
|
@@ -37,6 +37,10 @@ const path = require('path');
|
|
|
37
37
|
|
|
38
38
|
const HOST_ID = 'claude-code';
|
|
39
39
|
const CAPTURE_RECORD_VERSION = 'claude-capture-record/1';
|
|
40
|
+
const OPENCODE_HOST_ID = 'opencode';
|
|
41
|
+
const OPENCODE_CAPTURE_RECORD_VERSION = 'opencode-capture-record/1';
|
|
42
|
+
const OPENCODE_CAPTURE_FILE = 'opencode-captures.jsonl';
|
|
43
|
+
const OPENCODE_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
40
44
|
const QUALIFICATION_EXPORT_SCHEMA = 'harness.doc-reconciliation.qualification.v1';
|
|
41
45
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
42
46
|
const PRE_CAPTURE_TOOLS = ['Bash', 'PowerShell', 'Task', 'Agent'];
|
|
@@ -522,10 +526,14 @@ function observedCorrelation(records) {
|
|
|
522
526
|
|
|
523
527
|
// Validates the qualification export against the sanitized capture it names: schema, host, capture-record version, the
|
|
524
528
|
// capture's digest, and every claimed kind, field and correlation. Returns null when valid, else the reason.
|
|
525
|
-
function validateQualificationExport() {
|
|
529
|
+
function validateQualificationExport(expectedHost = HOST_ID) {
|
|
526
530
|
const exported = JSON.parse(fs.readFileSync(path.join(__dirname, EXPORT_FILE), 'utf8'));
|
|
527
531
|
if (!recordOf(exported) || exported.schema !== QUALIFICATION_EXPORT_SCHEMA) return 'qualification export schema is not recognized';
|
|
528
|
-
if (
|
|
532
|
+
if (!textOf(exported.hostVersion)) return 'qualification export host or host version is missing';
|
|
533
|
+
if (exported.host !== expectedHost) {
|
|
534
|
+
if (expectedHost === HOST_ID) return 'qualification export host or host version is missing';
|
|
535
|
+
return 'qualification export host does not match the selected provider';
|
|
536
|
+
}
|
|
529
537
|
if (exported.captureRecordVersion !== CAPTURE_RECORD_VERSION) return 'qualification export capture record version is not supported';
|
|
530
538
|
const source = recordOf(exported.sanitizedCapture) ? exported.sanitizedCapture : {};
|
|
531
539
|
// The sanitized capture is a sibling of the export, so source and packaged copies resolve it the same way.
|
|
@@ -547,7 +555,7 @@ function validateQualificationExport() {
|
|
|
547
555
|
return null;
|
|
548
556
|
}
|
|
549
557
|
|
|
550
|
-
function evaluateQualification(input) {
|
|
558
|
+
function evaluateQualification(input, expectedHost = HOST_ID) {
|
|
551
559
|
if (recordOf(input) && input.qualification !== undefined && input.qualification !== null) {
|
|
552
560
|
const supplied = input.qualification;
|
|
553
561
|
// Test-only DI path: an explicitly fake qualification object. Anything else
|
|
@@ -567,7 +575,7 @@ function evaluateQualification(input) {
|
|
|
567
575
|
// against the sanitized capture it names. Nothing is read from the environment, capture flags or ancestor directories.
|
|
568
576
|
let reason = null;
|
|
569
577
|
try {
|
|
570
|
-
reason = validateQualificationExport();
|
|
578
|
+
reason = validateQualificationExport(expectedHost);
|
|
571
579
|
} catch (error) {
|
|
572
580
|
reason = `qualification evidence could not be read (${error.code || 'error'})`;
|
|
573
581
|
}
|
|
@@ -582,11 +590,11 @@ function evaluateQualification(input) {
|
|
|
582
590
|
|
|
583
591
|
// ---- Shared provider state ------------------------------------------------------
|
|
584
592
|
|
|
585
|
-
function supportedHostCheck(input) {
|
|
586
|
-
if (recordOf(input) && input.hostId ===
|
|
593
|
+
function supportedHostCheck(input, expectedHost = HOST_ID) {
|
|
594
|
+
if ([HOST_ID, OPENCODE_HOST_ID].includes(expectedHost) && recordOf(input) && input.hostId === expectedHost) return null;
|
|
587
595
|
const host = recordOf(input) && typeof input.hostId === 'string' ? input.hostId : 'unknown';
|
|
588
596
|
return diagnostic('unsupported-host', {
|
|
589
|
-
reason: `unsupported host ${host} has no
|
|
597
|
+
reason: `unsupported host ${host} has no matching capture implementation; hosts must not borrow another host's exit mapping`,
|
|
590
598
|
});
|
|
591
599
|
}
|
|
592
600
|
|
|
@@ -892,6 +900,127 @@ const createClaudeOriginProvider = input => {
|
|
|
892
900
|
return { resolveContext, resolveEvent };
|
|
893
901
|
};
|
|
894
902
|
|
|
903
|
+
function opencodeContentSignature(cap) {
|
|
904
|
+
return captureJson({
|
|
905
|
+
hostId: cap.hostId,
|
|
906
|
+
tool: cap.tool === undefined ? null : cap.tool,
|
|
907
|
+
command: cap.command === undefined ? null : cap.command,
|
|
908
|
+
cwd: cap.cwd === undefined ? null : cap.cwd,
|
|
909
|
+
status: cap.status === undefined ? null : cap.status,
|
|
910
|
+
exit: cap.exit === undefined ? null : cap.exit,
|
|
911
|
+
resultDigest: cap.result_digest === undefined ? null : cap.result_digest,
|
|
912
|
+
error: cap.error === undefined ? null : cap.error,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const createOpencodeOriginProvider = input => {
|
|
917
|
+
const safeInput = recordOf(input) ? input : {};
|
|
918
|
+
|
|
919
|
+
function resolveContext(request) {
|
|
920
|
+
const hostFail = supportedHostCheck(safeInput, OPENCODE_HOST_ID);
|
|
921
|
+
if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
|
|
922
|
+
const qualification = evaluateQualification(safeInput, OPENCODE_HOST_ID);
|
|
923
|
+
if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
|
|
924
|
+
const workspaceRoot = textOf(safeInput.workspaceRoot) ? safeInput.workspaceRoot : (recordOf(request) && textOf(request.workspaceRoot) ? request.workspaceRoot : '');
|
|
925
|
+
const sessionId = textOf(safeInput.sessionId) ? safeInput.sessionId : '';
|
|
926
|
+
const workflowId = textOf(safeInput.workflowId) ? safeInput.workflowId : '';
|
|
927
|
+
const workflowRevision = Number.isInteger(safeInput.workflowRevision) ? safeInput.workflowRevision : null;
|
|
928
|
+
const taskId = textOf(safeInput.taskId) ? safeInput.taskId : '';
|
|
929
|
+
if (!workspaceRoot || !sessionId || !workflowId || workflowRevision === null || !taskId) {
|
|
930
|
+
return { status: 'unavailable', diagnostics: [diagnostic('invalid-input', { reason: 'OpenCode origin requires the recorded workspace, session, workflow, revision and task identity' })] };
|
|
931
|
+
}
|
|
932
|
+
const suppliedPrincipals = recordOf(safeInput.principals) ? safeInput.principals : {};
|
|
933
|
+
const author = textOf(suppliedPrincipals.author) ? suppliedPrincipals.author : sessionId;
|
|
934
|
+
const executors = Array.isArray(suppliedPrincipals.executors) ? suppliedPrincipals.executors.filter(textOf) : [sessionId];
|
|
935
|
+
const originRef = 'origin://opencode/' + digestText(captureJson({
|
|
936
|
+
hostId: OPENCODE_HOST_ID, sessionId, workspaceRoot, taskId, workflowId, workflowRevision,
|
|
937
|
+
author, executors, qualificationSource: qualification.source,
|
|
938
|
+
}));
|
|
939
|
+
return {
|
|
940
|
+
status: 'accepted', originRef,
|
|
941
|
+
context: {
|
|
942
|
+
workspaceId: 'ws-' + digestText(captureJson({ workspaceRoot })), sessionId, workflowId, workflowRevision, taskId,
|
|
943
|
+
principals: { author, executors: executors.slice() }, activeHost: OPENCODE_HOST_ID,
|
|
944
|
+
baselineReference: `retained-live-capture:opencode-341:${digestText(originRef)}`,
|
|
945
|
+
...(recordOf(safeInput.baseline) && textOf(safeInput.baseline.commit) && textOf(safeInput.baseline.root)
|
|
946
|
+
? { baseline: { commit: safeInput.baseline.commit, root: safeInput.baseline.root } } : {}),
|
|
947
|
+
workspaceRoot,
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function resolveEvent(request) {
|
|
953
|
+
if (!recordOf(request) || !textOf(request.reference)) return unavailable('invalid-input', 'resolveEvent requires a nonempty event reference');
|
|
954
|
+
const hostFail = supportedHostCheck(safeInput, OPENCODE_HOST_ID);
|
|
955
|
+
if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
|
|
956
|
+
const qualification = evaluateQualification(safeInput, OPENCODE_HOST_ID);
|
|
957
|
+
if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
|
|
958
|
+
const [captureRef, capability] = String(request.reference).split('#');
|
|
959
|
+
const captures = Array.isArray(safeInput.captures) ? safeInput.captures : [];
|
|
960
|
+
const matches = captures.map((item, index) => ({ item, index })).filter(({ item }) => recordOf(item) && item.call_id === captureRef);
|
|
961
|
+
if (!matches.length) return unavailable('origin-unavailable', `no captured OpenCode event is retained for reference ${request.reference}`);
|
|
962
|
+
if (matches.length > 1) return rejected('replayed-record', 'multiple OpenCode records share this call id');
|
|
963
|
+
const { item: cap, index } = matches[0];
|
|
964
|
+
if (cap.hostId !== OPENCODE_HOST_ID || cap.schema !== OPENCODE_CAPTURE_RECORD_VERSION
|
|
965
|
+
|| cap.session_id !== safeInput.sessionId) {
|
|
966
|
+
return rejected('record-origin-mismatch', 'the capture host and session must match this OpenCode provider invocation');
|
|
967
|
+
}
|
|
968
|
+
const observed = cap.observedIdentity;
|
|
969
|
+
const pin = recordOf(safeInput.baseline) ? safeInput.baseline : null;
|
|
970
|
+
if (!recordOf(observed)) return rejected('identity-missing', 'OpenCode capture lacks workflow identity observed at capture time');
|
|
971
|
+
const sameWorkflow = observed.workflowId === safeInput.workflowId && observed.workflowRevision === safeInput.workflowRevision;
|
|
972
|
+
const samePin = !pin || (recordOf(observed.baselinePin) && observed.baselinePin.commit === pin.commit && observed.baselinePin.root === pin.root);
|
|
973
|
+
if (!sameWorkflow || !samePin) return rejected('identity-mismatch', 'the observed workflow revision or baseline pin differs from the current one; stale evidence loses credit');
|
|
974
|
+
const signature = opencodeContentSignature(cap);
|
|
975
|
+
const firstOccurrence = captures.findIndex(item => recordOf(item) && opencodeContentSignature(item) === signature);
|
|
976
|
+
if (firstOccurrence !== index) return rejected('replayed-record', `identical OpenCode tool content already recorded (first occurrence index ${firstOccurrence})`);
|
|
977
|
+
if (cap.error !== undefined && cap.error !== null) return rejected('failure-not-credited', 'the native tool event carries an error');
|
|
978
|
+
if (typeof cap.status !== 'string' || !cap.status) return rejected('outcome-ambiguous', 'the native tool status is missing');
|
|
979
|
+
if (cap.status !== 'completed') return rejected(cap.status === 'error' ? 'failure-not-credited' : 'outcome-ambiguous', `native tool status ${cap.status} does not prove completion`);
|
|
980
|
+
const processTool = ['bash', 'shell', 'powershell', 'cmd'].includes(String(cap.tool).toLowerCase());
|
|
981
|
+
if (processTool && !Number.isInteger(cap.exit)) return rejected('outcome-ambiguous', 'a process result without a numeric native exit is ambiguous');
|
|
982
|
+
if (processTool && cap.exit !== 0) return rejected('failure-not-credited', `the native process exit was ${cap.exit}`);
|
|
983
|
+
if (!/^[0-9a-f]{64}$/.test(String(cap.result_digest || '')) || !Number.isInteger(cap.result_bytes) || cap.result_bytes < 0) {
|
|
984
|
+
return rejected('stdout-withheld', 'the native result digest and byte count are absent');
|
|
985
|
+
}
|
|
986
|
+
const author = recordOf(safeInput.principals) && textOf(safeInput.principals.author) ? safeInput.principals.author : safeInput.sessionId;
|
|
987
|
+
if (capability === 'quality-review') {
|
|
988
|
+
if (!textOf(cap.agent_id)) return rejected('reviewer-principal-missing', 'OpenCode supplied no host-issued reviewer identity');
|
|
989
|
+
if (cap.agent_id === author) return rejected('reviewer-not-independent', 'the reviewer identity equals the author session principal');
|
|
990
|
+
return rejected('reviewer-principal-missing', 'OpenCode supplied no host-issued independent-review record');
|
|
991
|
+
}
|
|
992
|
+
if (capability) {
|
|
993
|
+
if (!['doc-lint', 'link-check'].includes(capability)) return rejected('capability-mismatch', `OpenCode cannot supply capability ${capability}`);
|
|
994
|
+
if (!textOf(cap.command)) return rejected('command-missing', 'the native record has no command for a registered doc-check');
|
|
995
|
+
if (!textOf(cap.cwd)) return rejected('unregistered-command', 'the host omitted per-tool cwd, so the doc-check invocation cannot be validated');
|
|
996
|
+
const registered = resolveDocCheckInvocation(cap.command, cap.cwd);
|
|
997
|
+
if (registered.kind !== 'accepted') return rejected('unregistered-command', `doc-check invocation rejected (${registered.reason}); no credit`);
|
|
998
|
+
return rejected('stdout-withheld', 'the OpenCode result body is retained only as a digest and cannot prove doc-check output');
|
|
999
|
+
}
|
|
1000
|
+
if (!textOf(cap.command)) return rejected('command-missing', 'captured OpenCode record has no retained process command');
|
|
1001
|
+
if (!textOf(cap.cwd)) return rejected('outcome-ambiguous', 'the host omitted per-tool cwd');
|
|
1002
|
+
if (!processTool) return rejected('capture-event-unsupported', `tool ${String(cap.tool)} has no OpenCode process mapping`);
|
|
1003
|
+
if (!recordOf(request.identity)) return rejected('identity-missing', 'execution evidence needs a snapshot identity');
|
|
1004
|
+
const contextResponse = resolveContext(request);
|
|
1005
|
+
if (contextResponse.status !== 'accepted') return unavailable('origin-unavailable', 'origin context did not resolve');
|
|
1006
|
+
const identity = JSON.parse(JSON.stringify(request.identity));
|
|
1007
|
+
const resultRef = `opencode-result/${cap.call_id}/${cap.result_digest}`;
|
|
1008
|
+
const event = {
|
|
1009
|
+
eventId: 'opencode-exec-' + digestText(signature + ':execution'),
|
|
1010
|
+
producerId: 'opencode-origin-provider', originRef: contextResponse.originRef,
|
|
1011
|
+
principalId: cap.session_id, provenance: 'opencode-capture:' + cap.call_id,
|
|
1012
|
+
capability: 'shell-command', kind: 'execution', command: cap.command, argv: [cap.command], cwd: cap.cwd,
|
|
1013
|
+
exitCode: cap.exit, signal: null, toolCorrelationId: cap.call_id, resultRef,
|
|
1014
|
+
identity, startIdentity: identity, endIdentity: identity,
|
|
1015
|
+
paths: [], artifactDigests: {}, captured: { responseRef: resultRef, responseDigest: cap.result_digest },
|
|
1016
|
+
};
|
|
1017
|
+
return { status: 'accepted', originRef: contextResponse.originRef, event,
|
|
1018
|
+
correlation: { toolCorrelationId: cap.call_id, resultRef } };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { resolveContext, resolveEvent };
|
|
1022
|
+
};
|
|
1023
|
+
|
|
895
1024
|
// ---- Capture record builder (consumed by state-persist.js) ----------------------
|
|
896
1025
|
|
|
897
1026
|
// Builds the retained capture record for a Claude hook payload. The field names
|
|
@@ -1028,6 +1157,24 @@ function readCaptureView(sessionDir) {
|
|
|
1028
1157
|
return { records: view, tampered: false };
|
|
1029
1158
|
}
|
|
1030
1159
|
|
|
1160
|
+
function readOpencodeCaptureView(sessionDir) {
|
|
1161
|
+
const file = path.join(sessionDir, OPENCODE_CAPTURE_FILE);
|
|
1162
|
+
try {
|
|
1163
|
+
if (fs.statSync(file).size > OPENCODE_CAPTURE_LIMIT_BYTES) return { records: [], tampered: true };
|
|
1164
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
1165
|
+
const records = text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
|
|
1166
|
+
if (!records.every(item => recordOf(item) && item.schema === OPENCODE_CAPTURE_RECORD_VERSION
|
|
1167
|
+
&& item.hostId === OPENCODE_HOST_ID && textOf(item.session_id) && textOf(item.call_id)
|
|
1168
|
+
&& textOf(item.tool) && Object.prototype.hasOwnProperty.call(item, 'status'))) {
|
|
1169
|
+
return { records: [], tampered: true };
|
|
1170
|
+
}
|
|
1171
|
+
return { records, tampered: false };
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (error.code === 'ENOENT') return { records: [], tampered: false };
|
|
1174
|
+
return { records: [], tampered: true };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1031
1178
|
// Observation-time identity: the workflow and pin the host event was captured under. Absent before a workflow starts.
|
|
1032
1179
|
function observedIdentityFor(sessionDir) {
|
|
1033
1180
|
try {
|
|
@@ -1053,7 +1200,8 @@ function reviewRecordIndependent(cap, captures, principals) {
|
|
|
1053
1200
|
}
|
|
1054
1201
|
|
|
1055
1202
|
// Keep the old state-persist seam as an alias; it points at the single resolver above.
|
|
1056
|
-
module.exports = { createClaudeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
|
|
1203
|
+
module.exports = { createClaudeOriginProvider, createOpencodeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
|
|
1057
1204
|
registeredInstalledCheck: resolveDocCheckInvocation, installedDocCheck,
|
|
1058
1205
|
fileDigest, SHIPPED_DOC_CHECK_DIGEST, trustRisk, journalExecutors, reviewRecordIndependent,
|
|
1059
|
-
writeImmutableEvent, rebuildJournalView, readCaptureView,
|
|
1206
|
+
writeImmutableEvent, rebuildJournalView, readCaptureView, readOpencodeCaptureView, observedIdentityFor,
|
|
1207
|
+
_internals: { captureJson, captureDigest, digestText, canonicalCapture } };
|
|
@@ -14,6 +14,7 @@ function decide(payload) {
|
|
|
14
14
|
try {
|
|
15
15
|
const { planningUnresolved, unresolvedObligations } = require('./lib/workflow-obligations');
|
|
16
16
|
const context = loadWorkflow(payload);
|
|
17
|
+
context.activeHost = payload && typeof payload.host_id === 'string' ? payload.host_id : undefined;
|
|
17
18
|
const { workflow, sessionDir } = context;
|
|
18
19
|
if (!workflow || workflow.state === 'deferred') return;
|
|
19
20
|
const handoffFile = path.join(sessionDir, 'handoff-state.json');
|
|
@@ -93,4 +94,6 @@ function decide(payload) {
|
|
|
93
94
|
} catch (error) { warn(error && error.message ? error.message : String(error)); }
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
readHookInput(decide);
|
|
97
|
+
if (require.main === module) readHookInput(decide);
|
|
98
|
+
|
|
99
|
+
module.exports = { decide };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Harness Enforcement Plugin for opencode
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* An ESM entry module, because that is what opencode actually loads: a JS/TS
|
|
5
|
+
* file dropped in `.opencode/plugins/` (or referenced from `opencode.json`)
|
|
6
|
+
* that exports a function returning a hooks object. The optional workflow
|
|
7
|
+
* reconciliation path resolves the packaged sibling Stop consumer. There is
|
|
7
8
|
* no JSON-manifest-to-script mechanism - the old plugin.json + hooks/*.js
|
|
8
9
|
* split assumed one and was never invoked by opencode. See issue #37.
|
|
9
10
|
*
|
|
@@ -37,11 +38,28 @@
|
|
|
37
38
|
|
|
38
39
|
import { homedir } from "node:os"
|
|
39
40
|
import { join, dirname, resolve, basename, relative, isAbsolute } from "node:path"
|
|
40
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync } from "node:fs"
|
|
41
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync, statSync } from "node:fs"
|
|
41
42
|
import { execSync } from "node:child_process"
|
|
42
43
|
import { createHash, randomUUID } from "node:crypto"
|
|
44
|
+
import { createRequire } from "node:module"
|
|
45
|
+
import { fileURLToPath } from "node:url"
|
|
43
46
|
|
|
44
47
|
const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
|
|
48
|
+
const OPENCODE_CAPTURE_FILE = "opencode-captures.jsonl"
|
|
49
|
+
const OPENCODE_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024
|
|
50
|
+
const OPENCODE_CAPTURE_RECORD_LIMIT_BYTES = 16 * 1024
|
|
51
|
+
const requireFromPlugin = createRequire(import.meta.url)
|
|
52
|
+
const STOP_GATE_FILE = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "scripts", "workflow-stop-gate.js")
|
|
53
|
+
|
|
54
|
+
function canonicalJson(value) {
|
|
55
|
+
if (Array.isArray(value)) return value.map(canonicalJson)
|
|
56
|
+
if (isRecord(value)) return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]))
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function captureDigest(value) {
|
|
61
|
+
return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex")
|
|
62
|
+
}
|
|
45
63
|
|
|
46
64
|
// Global state root, matching the Node-side resolver at
|
|
47
65
|
// scripts/lib/workspace.js#getStateHome (same env var precedence) - this
|
|
@@ -744,6 +762,50 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
744
762
|
}
|
|
745
763
|
}
|
|
746
764
|
|
|
765
|
+
function recordHostCapture(input, output) {
|
|
766
|
+
try {
|
|
767
|
+
if (!isRecord(output) || output.status === undefined || typeof input?.sessionID !== "string" || !input.sessionID
|
|
768
|
+
|| typeof input.callID !== "string" || !input.callID || typeof input.tool !== "string" || !input.tool) return
|
|
769
|
+
const stateDir = pathsFor(input.sessionID).stateDir
|
|
770
|
+
const file = join(stateDir, OPENCODE_CAPTURE_FILE)
|
|
771
|
+
const args = isRecord(input.args) ? input.args : {}
|
|
772
|
+
const record = {
|
|
773
|
+
schema: "opencode-capture-record/1",
|
|
774
|
+
hostId: "opencode",
|
|
775
|
+
session_id: input.sessionID,
|
|
776
|
+
call_id: input.callID,
|
|
777
|
+
...(typeof output.messageID === "string" ? { message_id: output.messageID } : {}),
|
|
778
|
+
tool: input.tool,
|
|
779
|
+
status: output.status,
|
|
780
|
+
...(typeof args.command === "string" ? { command: args.command } : {}),
|
|
781
|
+
...(typeof args.cwd === "string" ? { cwd: args.cwd } : {}),
|
|
782
|
+
...(isRecord(output.result?.metadata) && typeof output.result.metadata.exit === "number" && Number.isFinite(output.result.metadata.exit)
|
|
783
|
+
? { exit: output.result.metadata.exit } : {}),
|
|
784
|
+
...(output.result !== undefined
|
|
785
|
+
? { result_digest: captureDigest(output.result), result_bytes: Buffer.byteLength(JSON.stringify(canonicalJson(output.result))) } : {}),
|
|
786
|
+
...(output.error !== undefined && output.error !== null
|
|
787
|
+
? { error: isRecord(output.error)
|
|
788
|
+
? Object.fromEntries(["name", "message", "code"].filter(key => typeof output.error[key] === "string").map(key => [key, output.error[key].slice(0, 300)]))
|
|
789
|
+
: typeof output.error === "string" ? { message: output.error.slice(0, 300) } : {} } : {}),
|
|
790
|
+
ts: new Date().toISOString(),
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const workflow = JSON.parse(readFileSync(join(stateDir, "workflow-run.json"), "utf8"))
|
|
794
|
+
if (typeof workflow.workflowId === "string" && Number.isInteger(workflow.revision)) {
|
|
795
|
+
const pin = isRecord(workflow.docReconciliationBaseline) ? workflow.docReconciliationBaseline : {}
|
|
796
|
+
record.observedIdentity = { workflowId: workflow.workflowId, workflowRevision: workflow.revision,
|
|
797
|
+
baselinePin: typeof pin.commit === "string" && typeof pin.root === "string" ? { commit: pin.commit, root: pin.root } : null }
|
|
798
|
+
}
|
|
799
|
+
} catch { /* pre-workflow events cannot be bound to a later workflow */ }
|
|
800
|
+
const line = `${JSON.stringify(record)}\n`
|
|
801
|
+
if (Buffer.byteLength(line) > OPENCODE_CAPTURE_RECORD_LIMIT_BYTES) return
|
|
802
|
+
mkdirSync(stateDir, { recursive: true })
|
|
803
|
+
const size = existsSync(file) ? statSync(file).size : 0
|
|
804
|
+
if (size + Buffer.byteLength(line) > OPENCODE_CAPTURE_LIMIT_BYTES) return
|
|
805
|
+
appendFileSync(file, line, "utf8")
|
|
806
|
+
} catch { /* capture is diagnostic only; an unavailable journal never grants credit */ }
|
|
807
|
+
}
|
|
808
|
+
|
|
747
809
|
function resetSession(sessionID) {
|
|
748
810
|
const { stateDir } = pathsFor(sessionID)
|
|
749
811
|
const sessionsRoot = resolve(join(getStateRoot(workspace), "sessions"))
|
|
@@ -789,6 +851,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
789
851
|
|
|
790
852
|
"tool.execute.after": async (input, output) => {
|
|
791
853
|
telemetryAfter(input)
|
|
854
|
+
recordHostCapture(input, output)
|
|
792
855
|
if (!EDIT_TOOLS.has(input.tool)) return
|
|
793
856
|
const { editStateFile, breakerFile, complianceFile, reflectionFile } = pathsFor(input.sessionID)
|
|
794
857
|
|
|
@@ -927,6 +990,9 @@ export const HarnessEnforcement = async ({ client, directory }) => {
|
|
|
927
990
|
saveJSON(editStateFile, failedState)
|
|
928
991
|
}
|
|
929
992
|
} finally {
|
|
993
|
+
try {
|
|
994
|
+
requireFromPlugin(STOP_GATE_FILE).decide({ session_id: activeSession, cwd: workspace, host_id: "opencode" })
|
|
995
|
+
} catch { /* the optional reconciliation consumer must never block OpenCode idle */ }
|
|
930
996
|
activeVerifications.delete(activeSession)
|
|
931
997
|
}
|
|
932
998
|
},
|
|
@@ -971,7 +1037,13 @@ export default {
|
|
|
971
1037
|
await ctx.tool.hook("execute.before", (event) =>
|
|
972
1038
|
hooks["tool.execute.before"](toolEventInput(event), { args: event.input }))
|
|
973
1039
|
await ctx.tool.hook("execute.after", (event) =>
|
|
974
|
-
hooks["tool.execute.after"](toolEventInput(event), {
|
|
1040
|
+
hooks["tool.execute.after"](toolEventInput(event), {
|
|
1041
|
+
args: event.input,
|
|
1042
|
+
status: event.status,
|
|
1043
|
+
result: event.result,
|
|
1044
|
+
error: event.error,
|
|
1045
|
+
messageID: event.messageID,
|
|
1046
|
+
}))
|
|
975
1047
|
|
|
976
1048
|
const events = new AbortController()
|
|
977
1049
|
void (async () => {
|
package/package.json
CHANGED
|
@@ -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 || '');
|