harness-everything 0.41.0 → 0.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +2 -2
- package/CHANGELOG.md +14 -0
- package/ci/mechanism-365-step-edit-accounting.test.js +395 -0
- package/ci/mechanism-codex-action-authorization.test.js +292 -0
- package/docs/codex-action-gate.md +22 -0
- package/docs/doc-workflow-reconciliation.md +13 -13
- package/docs/document-lifecycle.md +7 -5
- package/docs/platform-capabilities.md +17 -0
- package/hooks/scripts/action-gate.js +53 -3
- package/hooks/scripts/codex-user-prompt.js +20 -0
- package/hooks/scripts/lib/codex-action-authorization.js +193 -0
- package/hooks/scripts/state-persist.js +27 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/action-gate.js +53 -3
- package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +20 -0
- package/plugins/harness-everything/hooks/scripts/lib/codex-action-authorization.js +193 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +27 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cooperative Codex explicit-authorization state. This is NOT tamperproof:
|
|
4
|
+
// it is local hook state keyed by host session. A local process or user can
|
|
5
|
+
// delete or rewrite it. It records consent only; it never bypasses Codex's
|
|
6
|
+
// native permission controls.
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const { getWorkspaceRoot, getSessionDir } = require('./harness-state');
|
|
12
|
+
|
|
13
|
+
const DIR_NAME = 'codex-action-authorization';
|
|
14
|
+
const CHALLENGES = 'challenges';
|
|
15
|
+
const GRANTS = 'grants';
|
|
16
|
+
|
|
17
|
+
function sessionDirOf(payload, options) {
|
|
18
|
+
if (options && options.sessionDir) return options.sessionDir;
|
|
19
|
+
const root = (options && options.root) || getWorkspaceRoot(payload);
|
|
20
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
21
|
+
return getSessionDir(root, sessionId, payload);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeToken(value) {
|
|
25
|
+
return String(value || 'x').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 160) || 'x';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authDir(payload, options, leaf) {
|
|
29
|
+
return path.join(sessionDirOf(payload, options), DIR_NAME, leaf);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function challengesDir(payload, options) { return authDir(payload, options, CHALLENGES); }
|
|
33
|
+
function grantsDir(payload, options) { return authDir(payload, options, GRANTS); }
|
|
34
|
+
|
|
35
|
+
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
36
|
+
|
|
37
|
+
function readJson(file) {
|
|
38
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function atomicWriteJson(filePath, value) {
|
|
42
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
45
|
+
fs.renameSync(temp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function newChallengeId() {
|
|
49
|
+
return `ch-${crypto.randomBytes(12).toString('hex')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Canonical challenge ids are minted by this helper as ch-<24 hex>. A pending
|
|
53
|
+
// grant is stored as exactly <challengeId>.json. Anything with an extra suffix
|
|
54
|
+
// (.consumed, .consumed.json, etc.) is a claim/consumed record, not pending.
|
|
55
|
+
const CANONICAL_CHALLENGE_ID = /^ch-[0-9a-f]{24}$/;
|
|
56
|
+
function isPendingGrantFileName(name) {
|
|
57
|
+
if (typeof name !== 'string') return false;
|
|
58
|
+
const match = name.match(/^([^/\\]+)\.json$/);
|
|
59
|
+
if (!match) return false;
|
|
60
|
+
return CANONICAL_CHALLENGE_ID.test(match[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolNameOf(payload) {
|
|
64
|
+
return String((payload && (payload.tool_name || payload.toolName)) || '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validIdentity(value) {
|
|
68
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function actionGateModule() {
|
|
72
|
+
try { return require(path.join(__dirname, '..', 'action-gate.js')); } catch (_) { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canonicalDigest(payload) {
|
|
76
|
+
const ag = actionGateModule();
|
|
77
|
+
if (ag && typeof ag.exactPayloadText === 'function' && typeof ag.hashExact === 'function') {
|
|
78
|
+
return ag.hashExact(ag.exactPayloadText(payload));
|
|
79
|
+
}
|
|
80
|
+
const input = (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
|
|
81
|
+
return crypto.createHash('sha256').update(JSON.stringify(input), 'utf8').digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function issueChallenge(payload, options) {
|
|
85
|
+
options = options || {};
|
|
86
|
+
const challengeId = options.challengeId || newChallengeId();
|
|
87
|
+
const dir = challengesDir(payload, options);
|
|
88
|
+
ensureDir(dir);
|
|
89
|
+
const file = path.join(dir, `${safeToken(challengeId)}.json`);
|
|
90
|
+
const record = {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
challengeId,
|
|
93
|
+
sessionId: payload && (payload.session_id || payload.sessionId) || null,
|
|
94
|
+
toolName: options.toolName || toolNameOf(payload),
|
|
95
|
+
digest: options.digest || canonicalDigest(payload),
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
atomicWriteJson(file, record);
|
|
99
|
+
return challengeId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bindConsent(payload, options) {
|
|
103
|
+
options = options || {};
|
|
104
|
+
const challengeId = options.challengeId;
|
|
105
|
+
if (!challengeId) return { bound: false, reason: 'missing-challenge-id' };
|
|
106
|
+
|
|
107
|
+
const dir = challengesDir(payload, options);
|
|
108
|
+
const file = path.join(dir, `${safeToken(challengeId)}.json`);
|
|
109
|
+
const challenge = readJson(file);
|
|
110
|
+
if (!challenge) return { bound: false, reason: 'challenge-not-found' };
|
|
111
|
+
|
|
112
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
113
|
+
if (challenge.sessionId && challenge.sessionId !== sessionId) {
|
|
114
|
+
return { bound: false, reason: 'session-mismatch' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const consentTurn = payload && (payload.turn_id || payload.turnId);
|
|
118
|
+
if (!validIdentity(sessionId) || !validIdentity(consentTurn)) {
|
|
119
|
+
return { bound: false, reason: 'missing-identity' };
|
|
120
|
+
}
|
|
121
|
+
const gdir = grantsDir(payload, options);
|
|
122
|
+
ensureDir(gdir);
|
|
123
|
+
const grantFile = path.join(gdir, `${safeToken(challengeId)}.json`);
|
|
124
|
+
const grant = {
|
|
125
|
+
schemaVersion: 1,
|
|
126
|
+
challengeId,
|
|
127
|
+
sessionId,
|
|
128
|
+
consentTurn: consentTurn || null,
|
|
129
|
+
toolName: challenge.toolName,
|
|
130
|
+
digest: challenge.digest,
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
consumed: false,
|
|
133
|
+
};
|
|
134
|
+
atomicWriteJson(grantFile, grant);
|
|
135
|
+
return { bound: true, challenge };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function consumeGrantIfValid(payload, options) {
|
|
139
|
+
options = options || {};
|
|
140
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
141
|
+
const turnId = payload && (payload.turn_id || payload.turnId);
|
|
142
|
+
if (!validIdentity(sessionId) || !validIdentity(turnId)) {
|
|
143
|
+
return { authorized: false };
|
|
144
|
+
}
|
|
145
|
+
const toolName = toolNameOf(payload);
|
|
146
|
+
const digest = canonicalDigest(payload);
|
|
147
|
+
|
|
148
|
+
const dir = grantsDir(payload, options);
|
|
149
|
+
let files = [];
|
|
150
|
+
try { files = fs.readdirSync(dir).filter(isPendingGrantFileName); } catch (_) { files = []; }
|
|
151
|
+
|
|
152
|
+
for (const name of files) {
|
|
153
|
+
const file = path.join(dir, name);
|
|
154
|
+
const grant = readJson(file);
|
|
155
|
+
if (!grant || grant.consumed) continue;
|
|
156
|
+
if (grant.sessionId !== sessionId) continue;
|
|
157
|
+
if (grant.consentTurn !== turnId) continue; // cross-turn / expired
|
|
158
|
+
if (grant.toolName !== toolName) continue;
|
|
159
|
+
if (grant.digest !== digest) continue;
|
|
160
|
+
|
|
161
|
+
// Atomic consume: first rename wins.
|
|
162
|
+
const consumedFile = path.join(dir, `${path.basename(name, '.json')}.consumed`);
|
|
163
|
+
try {
|
|
164
|
+
fs.renameSync(file, consumedFile);
|
|
165
|
+
} catch (_) {
|
|
166
|
+
continue; // lost race or already consumed
|
|
167
|
+
}
|
|
168
|
+
grant.consumed = true;
|
|
169
|
+
grant.consumedAt = new Date().toISOString();
|
|
170
|
+
try { atomicWriteJson(consumedFile, grant); } catch (_) { /* best effort */ }
|
|
171
|
+
return { authorized: true };
|
|
172
|
+
}
|
|
173
|
+
return { authorized: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isExactConsentPrompt(prompt) {
|
|
177
|
+
return /^HARNESS APPROVE [A-Za-z0-9._-]{8,}$/.test(String(prompt || ''));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseConsentPrompt(prompt) {
|
|
181
|
+
const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
|
|
182
|
+
return match ? match[1] : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
bindConsent,
|
|
187
|
+
consumeGrantIfValid,
|
|
188
|
+
issueChallenge,
|
|
189
|
+
isExactConsentPrompt,
|
|
190
|
+
parseConsentPrompt,
|
|
191
|
+
canonicalDigest,
|
|
192
|
+
toolNameOf,
|
|
193
|
+
};
|
|
@@ -5,6 +5,30 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
|
|
|
5
5
|
const { cwdOf, classifyShell, isVerificationShell, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
6
6
|
const { observeTool } = require('./lib/telemetry');
|
|
7
7
|
|
|
8
|
+
function isSuccessfulMetadataStep(command, cwd) {
|
|
9
|
+
// A line break starts a second shell command, so it can never be metadata-only.
|
|
10
|
+
if (/[\r\n]/.test(String(command || ''))) return false;
|
|
11
|
+
const text = String(command || '').trim();
|
|
12
|
+
if (/[$`%!^]/.test(text)) return false;
|
|
13
|
+
const value = '(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[^\\s;&|<>`$(){}]+)';
|
|
14
|
+
const pattern = new RegExp(
|
|
15
|
+
`^node\\s+(?<script>${value})\\s+step\\s+--session-id\\s+(?<session>${value})` +
|
|
16
|
+
`\\s+--step-id\\s+(?<step>${value})\\s+--disposition\\s+(?<disposition>pass|blocked)` +
|
|
17
|
+
`(?:\\s+--reason-code\\s+(?<reason>${value}))?\\s+--evidence\\s+(?<evidence>${value})$`
|
|
18
|
+
);
|
|
19
|
+
const match = pattern.exec(text);
|
|
20
|
+
if (!match) return false;
|
|
21
|
+
const unquote = token => token && token.length >= 2 &&
|
|
22
|
+
((token[0] === '"' && token[token.length - 1] === '"') || (token[0] === "'" && token[token.length - 1] === "'"))
|
|
23
|
+
? token.slice(1, -1) : token || '';
|
|
24
|
+
const { script, session, step, disposition, reason, evidence } = match.groups;
|
|
25
|
+
return path.resolve(cwd, unquote(script)) === path.join(__dirname, 'workflow-disposition.js')
|
|
26
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(session))
|
|
27
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(step))
|
|
28
|
+
&& Boolean(unquote(evidence).trim())
|
|
29
|
+
&& (disposition === 'pass' ? reason === undefined : /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(reason)));
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
// S2b doc-reconciliation pilot capture (docs/doc-workflow-reconciliation.md
|
|
9
33
|
// § S2b "Activation and pilot-only scope", "Claude capture and origin-provider
|
|
10
34
|
// contract"): pilot-only, fail-open to the host, and gated on the exact
|
|
@@ -75,7 +99,9 @@ function processState(payload) {
|
|
|
75
99
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
76
100
|
if (isShell) {
|
|
77
101
|
const commandClass = classifyShell(command);
|
|
78
|
-
|
|
102
|
+
const metadataStep = !isFailed && (isSuccessEvent || exitCode === 0)
|
|
103
|
+
&& isSuccessfulMetadataStep(command, cwdOf(payload, root));
|
|
104
|
+
if (!metadataStep && commandClass !== 'read-only' && commandClass !== 'worktree-setup') state.lastEditAt = Date.now();
|
|
79
105
|
// Verification evidence needs a confirmed success: a PostToolUse event or an explicit zero exit.
|
|
80
106
|
// Pilot ON only: a registered, argv-validated doc-check that resolves to this install's own runtime is the document
|
|
81
107
|
// verification evidence, and only at the shipped pin (R3-02). Any other path earns no verification evidence. OFF stays legacy.
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
getWorkspaceRoot,
|
|
10
10
|
getSessionDir,
|
|
11
11
|
} = require('./lib/harness-state');
|
|
12
|
+
const codexActionAuthorization = require('./lib/codex-action-authorization');
|
|
12
13
|
|
|
13
14
|
const RULES_PATH = process.env.HARNESS_ACTION_GATE_RULES_PATH || path.join(__dirname, 'action-gate-rules.json');
|
|
14
15
|
const ASK_CAPABLE_HOSTS = new Set(['claude']);
|
|
@@ -52,10 +53,12 @@ function toolInputOf(payload) {
|
|
|
52
53
|
function stableValue(value) {
|
|
53
54
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
54
55
|
if (!value || typeof value !== 'object') return value;
|
|
56
|
+
// Use a null-prototype accumulator so an own `__proto__` key is stored as data
|
|
57
|
+
// instead of mutating the accumulator's prototype (which would drop it).
|
|
55
58
|
return Object.keys(value).sort().reduce((acc, key) => {
|
|
56
59
|
acc[key] = stableValue(value[key]);
|
|
57
60
|
return acc;
|
|
58
|
-
},
|
|
61
|
+
}, Object.create(null));
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
function exactPayloadText(payload) {
|
|
@@ -445,11 +448,45 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
|
|
|
445
448
|
return record;
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
|
|
452
|
+
function isScopedCodexStructuredMutation(structured, rule) {
|
|
453
|
+
return Boolean(
|
|
454
|
+
rule && rule.id === 'structured-mutate' &&
|
|
455
|
+
structured && structured.descriptor &&
|
|
456
|
+
structured.descriptor.toolFamily !== 'shell' &&
|
|
457
|
+
structured.descriptor.action === 'mutate' &&
|
|
458
|
+
structured.descriptor.classification === 'gated'
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function safeInline(value) {
|
|
463
|
+
return String(value == null ? '' : value).replace(/[\r\n\t\u0000-\u001f\u007f]/g, '?').slice(0, 200);
|
|
464
|
+
}
|
|
465
|
+
|
|
448
466
|
function evaluatePreToolUse(payload, options = {}) {
|
|
449
467
|
const context = sessionContext(payload, options.sessionDir);
|
|
450
468
|
const table = options.ruleTable || loadRuleTable(options.rulePath || RULES_PATH);
|
|
451
469
|
const structured = structuredRule(payload);
|
|
452
470
|
const rule = structured.rule || classify(payload, table.rules, context.sessionDir);
|
|
471
|
+
const host = detectHost(payload, options.host);
|
|
472
|
+
|
|
473
|
+
// Codex explicit authorization applies only to scoped structured mutations.
|
|
474
|
+
// Shell-governed and other structured actions keep ordinary blocking.
|
|
475
|
+
if (host === 'codex' && isScopedCodexStructuredMutation(structured, rule)) {
|
|
476
|
+
const consumed = codexActionAuthorization.consumeGrantIfValid(payload, { sessionDir: context.sessionDir });
|
|
477
|
+
if (consumed && consumed.authorized) {
|
|
478
|
+
return {
|
|
479
|
+
kind: 'allow',
|
|
480
|
+
exitCode: 0,
|
|
481
|
+
stdout: null,
|
|
482
|
+
stderr: null,
|
|
483
|
+
rule: null,
|
|
484
|
+
table,
|
|
485
|
+
descriptor: structured.descriptor,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
453
490
|
if (!rule) {
|
|
454
491
|
return {
|
|
455
492
|
kind: 'allow',
|
|
@@ -462,7 +499,6 @@ function evaluatePreToolUse(payload, options = {}) {
|
|
|
462
499
|
};
|
|
463
500
|
}
|
|
464
501
|
|
|
465
|
-
const host = detectHost(payload, options.host);
|
|
466
502
|
const reason = decisionReason(rule, table);
|
|
467
503
|
if (hostSupportsAsk(host) && gatePolicy(options) === ALWAYS_ASK_POLICY) {
|
|
468
504
|
const record = recordDecision(payload, rule, table, host, 'pending-approval', { ...options, sessionDir: context.sessionDir });
|
|
@@ -478,11 +514,25 @@ function evaluatePreToolUse(payload, options = {}) {
|
|
|
478
514
|
const record = recordDecision(payload, rule, table, host, 'rejected', { ...options, sessionDir: context.sessionDir }, {
|
|
479
515
|
rejectionReason: 'host-ask-decision-unverified-or-unsupported',
|
|
480
516
|
});
|
|
517
|
+
let challengeSuffix = '';
|
|
518
|
+
let detailsSuffix = '';
|
|
519
|
+
if (host === 'codex' && isScopedCodexStructuredMutation(structured, rule)) {
|
|
520
|
+
const challengeId = codexActionAuthorization.issueChallenge(payload, {
|
|
521
|
+
sessionDir: context.sessionDir,
|
|
522
|
+
toolName: toolNameOf(payload),
|
|
523
|
+
digest: (structured.descriptor && structured.descriptor.payloadHash) || hashExact(exactPayloadText(payload)),
|
|
524
|
+
});
|
|
525
|
+
if (challengeId) {
|
|
526
|
+
challengeSuffix = ` challenge id: ${challengeId}`;
|
|
527
|
+
const digest = (structured.descriptor && structured.descriptor.payloadHash) || hashExact(exactPayloadText(payload));
|
|
528
|
+
detailsSuffix = ` Tool: ${safeInline(toolNameOf(payload))}. Input SHA-256: ${digest}. Approve with: HARNESS APPROVE ${challengeId}.`;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
481
531
|
return {
|
|
482
532
|
kind: 'block',
|
|
483
533
|
exitCode: 2,
|
|
484
534
|
stdout: null,
|
|
485
|
-
stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked
|
|
535
|
+
stderr: `${reason} Host '${host}' has no Harness-verified ask-style decision path, so the action is blocked.${challengeSuffix}${detailsSuffix}`,
|
|
486
536
|
rule,
|
|
487
537
|
table,
|
|
488
538
|
record,
|
|
@@ -22,6 +22,26 @@ function finish() {
|
|
|
22
22
|
if (finished) return;
|
|
23
23
|
finished = true;
|
|
24
24
|
clearTimeout(timer);
|
|
25
|
+
|
|
26
|
+
let payload = null;
|
|
27
|
+
try { payload = JSON.parse(input || '{}'); } catch (_) { payload = null; }
|
|
28
|
+
|
|
29
|
+
// Codex explicit authorization: only an exact host-submitted consent prompt
|
|
30
|
+
// may bind a pending challenge to the host session + consent turn. No
|
|
31
|
+
// natural-language inference, no tool-input claim.
|
|
32
|
+
if (payload && payload.hook_event_name === 'UserPromptSubmit') {
|
|
33
|
+
const prompt = payload.prompt;
|
|
34
|
+
const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
|
|
35
|
+
if (match) {
|
|
36
|
+
try {
|
|
37
|
+
const auth = require('./lib/codex-action-authorization');
|
|
38
|
+
auth.bindConsent(payload, { challengeId: match[1] });
|
|
39
|
+
} catch (_) { /* consent state is best-effort; failures do not authorize */ }
|
|
40
|
+
process.exit(0);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
if (!ROUTER) process.exit(0);
|
|
26
46
|
const result = spawnSync(process.execPath, [ROUTER], { input, encoding: 'utf8', env: process.env, timeout: 25000 });
|
|
27
47
|
const stderr = String(result.stderr || '');
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cooperative Codex explicit-authorization state. This is NOT tamperproof:
|
|
4
|
+
// it is local hook state keyed by host session. A local process or user can
|
|
5
|
+
// delete or rewrite it. It records consent only; it never bypasses Codex's
|
|
6
|
+
// native permission controls.
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const { getWorkspaceRoot, getSessionDir } = require('./harness-state');
|
|
12
|
+
|
|
13
|
+
const DIR_NAME = 'codex-action-authorization';
|
|
14
|
+
const CHALLENGES = 'challenges';
|
|
15
|
+
const GRANTS = 'grants';
|
|
16
|
+
|
|
17
|
+
function sessionDirOf(payload, options) {
|
|
18
|
+
if (options && options.sessionDir) return options.sessionDir;
|
|
19
|
+
const root = (options && options.root) || getWorkspaceRoot(payload);
|
|
20
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
21
|
+
return getSessionDir(root, sessionId, payload);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeToken(value) {
|
|
25
|
+
return String(value || 'x').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 160) || 'x';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authDir(payload, options, leaf) {
|
|
29
|
+
return path.join(sessionDirOf(payload, options), DIR_NAME, leaf);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function challengesDir(payload, options) { return authDir(payload, options, CHALLENGES); }
|
|
33
|
+
function grantsDir(payload, options) { return authDir(payload, options, GRANTS); }
|
|
34
|
+
|
|
35
|
+
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
36
|
+
|
|
37
|
+
function readJson(file) {
|
|
38
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function atomicWriteJson(filePath, value) {
|
|
42
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
45
|
+
fs.renameSync(temp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function newChallengeId() {
|
|
49
|
+
return `ch-${crypto.randomBytes(12).toString('hex')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Canonical challenge ids are minted by this helper as ch-<24 hex>. A pending
|
|
53
|
+
// grant is stored as exactly <challengeId>.json. Anything with an extra suffix
|
|
54
|
+
// (.consumed, .consumed.json, etc.) is a claim/consumed record, not pending.
|
|
55
|
+
const CANONICAL_CHALLENGE_ID = /^ch-[0-9a-f]{24}$/;
|
|
56
|
+
function isPendingGrantFileName(name) {
|
|
57
|
+
if (typeof name !== 'string') return false;
|
|
58
|
+
const match = name.match(/^([^/\\]+)\.json$/);
|
|
59
|
+
if (!match) return false;
|
|
60
|
+
return CANONICAL_CHALLENGE_ID.test(match[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolNameOf(payload) {
|
|
64
|
+
return String((payload && (payload.tool_name || payload.toolName)) || '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validIdentity(value) {
|
|
68
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function actionGateModule() {
|
|
72
|
+
try { return require(path.join(__dirname, '..', 'action-gate.js')); } catch (_) { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canonicalDigest(payload) {
|
|
76
|
+
const ag = actionGateModule();
|
|
77
|
+
if (ag && typeof ag.exactPayloadText === 'function' && typeof ag.hashExact === 'function') {
|
|
78
|
+
return ag.hashExact(ag.exactPayloadText(payload));
|
|
79
|
+
}
|
|
80
|
+
const input = (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
|
|
81
|
+
return crypto.createHash('sha256').update(JSON.stringify(input), 'utf8').digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function issueChallenge(payload, options) {
|
|
85
|
+
options = options || {};
|
|
86
|
+
const challengeId = options.challengeId || newChallengeId();
|
|
87
|
+
const dir = challengesDir(payload, options);
|
|
88
|
+
ensureDir(dir);
|
|
89
|
+
const file = path.join(dir, `${safeToken(challengeId)}.json`);
|
|
90
|
+
const record = {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
challengeId,
|
|
93
|
+
sessionId: payload && (payload.session_id || payload.sessionId) || null,
|
|
94
|
+
toolName: options.toolName || toolNameOf(payload),
|
|
95
|
+
digest: options.digest || canonicalDigest(payload),
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
atomicWriteJson(file, record);
|
|
99
|
+
return challengeId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bindConsent(payload, options) {
|
|
103
|
+
options = options || {};
|
|
104
|
+
const challengeId = options.challengeId;
|
|
105
|
+
if (!challengeId) return { bound: false, reason: 'missing-challenge-id' };
|
|
106
|
+
|
|
107
|
+
const dir = challengesDir(payload, options);
|
|
108
|
+
const file = path.join(dir, `${safeToken(challengeId)}.json`);
|
|
109
|
+
const challenge = readJson(file);
|
|
110
|
+
if (!challenge) return { bound: false, reason: 'challenge-not-found' };
|
|
111
|
+
|
|
112
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
113
|
+
if (challenge.sessionId && challenge.sessionId !== sessionId) {
|
|
114
|
+
return { bound: false, reason: 'session-mismatch' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const consentTurn = payload && (payload.turn_id || payload.turnId);
|
|
118
|
+
if (!validIdentity(sessionId) || !validIdentity(consentTurn)) {
|
|
119
|
+
return { bound: false, reason: 'missing-identity' };
|
|
120
|
+
}
|
|
121
|
+
const gdir = grantsDir(payload, options);
|
|
122
|
+
ensureDir(gdir);
|
|
123
|
+
const grantFile = path.join(gdir, `${safeToken(challengeId)}.json`);
|
|
124
|
+
const grant = {
|
|
125
|
+
schemaVersion: 1,
|
|
126
|
+
challengeId,
|
|
127
|
+
sessionId,
|
|
128
|
+
consentTurn: consentTurn || null,
|
|
129
|
+
toolName: challenge.toolName,
|
|
130
|
+
digest: challenge.digest,
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
consumed: false,
|
|
133
|
+
};
|
|
134
|
+
atomicWriteJson(grantFile, grant);
|
|
135
|
+
return { bound: true, challenge };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function consumeGrantIfValid(payload, options) {
|
|
139
|
+
options = options || {};
|
|
140
|
+
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
141
|
+
const turnId = payload && (payload.turn_id || payload.turnId);
|
|
142
|
+
if (!validIdentity(sessionId) || !validIdentity(turnId)) {
|
|
143
|
+
return { authorized: false };
|
|
144
|
+
}
|
|
145
|
+
const toolName = toolNameOf(payload);
|
|
146
|
+
const digest = canonicalDigest(payload);
|
|
147
|
+
|
|
148
|
+
const dir = grantsDir(payload, options);
|
|
149
|
+
let files = [];
|
|
150
|
+
try { files = fs.readdirSync(dir).filter(isPendingGrantFileName); } catch (_) { files = []; }
|
|
151
|
+
|
|
152
|
+
for (const name of files) {
|
|
153
|
+
const file = path.join(dir, name);
|
|
154
|
+
const grant = readJson(file);
|
|
155
|
+
if (!grant || grant.consumed) continue;
|
|
156
|
+
if (grant.sessionId !== sessionId) continue;
|
|
157
|
+
if (grant.consentTurn !== turnId) continue; // cross-turn / expired
|
|
158
|
+
if (grant.toolName !== toolName) continue;
|
|
159
|
+
if (grant.digest !== digest) continue;
|
|
160
|
+
|
|
161
|
+
// Atomic consume: first rename wins.
|
|
162
|
+
const consumedFile = path.join(dir, `${path.basename(name, '.json')}.consumed`);
|
|
163
|
+
try {
|
|
164
|
+
fs.renameSync(file, consumedFile);
|
|
165
|
+
} catch (_) {
|
|
166
|
+
continue; // lost race or already consumed
|
|
167
|
+
}
|
|
168
|
+
grant.consumed = true;
|
|
169
|
+
grant.consumedAt = new Date().toISOString();
|
|
170
|
+
try { atomicWriteJson(consumedFile, grant); } catch (_) { /* best effort */ }
|
|
171
|
+
return { authorized: true };
|
|
172
|
+
}
|
|
173
|
+
return { authorized: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isExactConsentPrompt(prompt) {
|
|
177
|
+
return /^HARNESS APPROVE [A-Za-z0-9._-]{8,}$/.test(String(prompt || ''));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseConsentPrompt(prompt) {
|
|
181
|
+
const match = String(prompt || '').match(/^HARNESS APPROVE ([A-Za-z0-9._-]{8,})$/);
|
|
182
|
+
return match ? match[1] : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
bindConsent,
|
|
187
|
+
consumeGrantIfValid,
|
|
188
|
+
issueChallenge,
|
|
189
|
+
isExactConsentPrompt,
|
|
190
|
+
parseConsentPrompt,
|
|
191
|
+
canonicalDigest,
|
|
192
|
+
toolNameOf,
|
|
193
|
+
};
|
|
@@ -5,6 +5,30 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
|
|
|
5
5
|
const { cwdOf, classifyShell, isVerificationShell, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
6
6
|
const { observeTool } = require('./lib/telemetry');
|
|
7
7
|
|
|
8
|
+
function isSuccessfulMetadataStep(command, cwd) {
|
|
9
|
+
// A line break starts a second shell command, so it can never be metadata-only.
|
|
10
|
+
if (/[\r\n]/.test(String(command || ''))) return false;
|
|
11
|
+
const text = String(command || '').trim();
|
|
12
|
+
if (/[$`%!^]/.test(text)) return false;
|
|
13
|
+
const value = '(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[^\\s;&|<>`$(){}]+)';
|
|
14
|
+
const pattern = new RegExp(
|
|
15
|
+
`^node\\s+(?<script>${value})\\s+step\\s+--session-id\\s+(?<session>${value})` +
|
|
16
|
+
`\\s+--step-id\\s+(?<step>${value})\\s+--disposition\\s+(?<disposition>pass|blocked)` +
|
|
17
|
+
`(?:\\s+--reason-code\\s+(?<reason>${value}))?\\s+--evidence\\s+(?<evidence>${value})$`
|
|
18
|
+
);
|
|
19
|
+
const match = pattern.exec(text);
|
|
20
|
+
if (!match) return false;
|
|
21
|
+
const unquote = token => token && token.length >= 2 &&
|
|
22
|
+
((token[0] === '"' && token[token.length - 1] === '"') || (token[0] === "'" && token[token.length - 1] === "'"))
|
|
23
|
+
? token.slice(1, -1) : token || '';
|
|
24
|
+
const { script, session, step, disposition, reason, evidence } = match.groups;
|
|
25
|
+
return path.resolve(cwd, unquote(script)) === path.join(__dirname, 'workflow-disposition.js')
|
|
26
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(session))
|
|
27
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(step))
|
|
28
|
+
&& Boolean(unquote(evidence).trim())
|
|
29
|
+
&& (disposition === 'pass' ? reason === undefined : /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(unquote(reason)));
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
// S2b doc-reconciliation pilot capture (docs/doc-workflow-reconciliation.md
|
|
9
33
|
// § S2b "Activation and pilot-only scope", "Claude capture and origin-provider
|
|
10
34
|
// contract"): pilot-only, fail-open to the host, and gated on the exact
|
|
@@ -75,7 +99,9 @@ function processState(payload) {
|
|
|
75
99
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
76
100
|
if (isShell) {
|
|
77
101
|
const commandClass = classifyShell(command);
|
|
78
|
-
|
|
102
|
+
const metadataStep = !isFailed && (isSuccessEvent || exitCode === 0)
|
|
103
|
+
&& isSuccessfulMetadataStep(command, cwdOf(payload, root));
|
|
104
|
+
if (!metadataStep && commandClass !== 'read-only' && commandClass !== 'worktree-setup') state.lastEditAt = Date.now();
|
|
79
105
|
// Verification evidence needs a confirmed success: a PostToolUse event or an explicit zero exit.
|
|
80
106
|
// Pilot ON only: a registered, argv-validated doc-check that resolves to this install's own runtime is the document
|
|
81
107
|
// verification evidence, and only at the shipped pin (R3-02). Any other path earns no verification evidence. OFF stays legacy.
|
|
@@ -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.2",
|
|
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:7e234a8b-69cb-55df-a4b5-86c44515fd0c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-10-
|
|
8
|
+
"timestamp": "2026-10-25T03:28:58.892Z",
|
|
9
9
|
"tools": [
|
|
10
10
|
{
|
|
11
11
|
"vendor": "Harness Everything",
|
|
12
12
|
"name": "generate-sbom.js",
|
|
13
|
-
"version": "0.41.
|
|
13
|
+
"version": "0.41.2"
|
|
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.2",
|
|
19
19
|
"name": "harness-everything",
|
|
20
|
-
"version": "0.41.
|
|
21
|
-
"purl": "pkg:npm/harness-everything@0.41.
|
|
20
|
+
"version": "0.41.2",
|
|
21
|
+
"purl": "pkg:npm/harness-everything@0.41.2"
|
|
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.2",
|
|
2037
2037
|
"dependsOn": [
|
|
2038
2038
|
"pkg:npm/%40commitlint/cli@19.8.1",
|
|
2039
2039
|
"pkg:npm/%40commitlint/config-conventional@19.8.1",
|