harness-everything 0.11.4 → 0.11.6
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 +25 -0
- package/ci/mechanism-2af-worktree-isolation.test.js +12 -0
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +200 -8
- package/hooks/hooks.json +25 -1
- package/hooks/scripts/lib/workflow-isolation.js +176 -19
- package/hooks/scripts/lib/workflow-runtime.js +138 -2
- package/hooks/scripts/state-persist.js +41 -6
- package/hooks/scripts/workflow-gate.js +18 -7
- package/hooks/scripts/workflow-mutation-denied.js +17 -0
- 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/hooks.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +176 -19
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +138 -2
- package/plugins/harness-everything/hooks/scripts/state-persist.js +41 -6
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +18 -7
- package/plugins/harness-everything/hooks/scripts/workflow-mutation-denied.js +17 -0
- package/plugins/harness-everything/plugin.json +1 -1
|
@@ -43,6 +43,140 @@ function loadWorkflow(payload) {
|
|
|
43
43
|
|
|
44
44
|
function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow); }
|
|
45
45
|
|
|
46
|
+
const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
|
|
47
|
+
const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
48
|
+
|
|
49
|
+
function mutationProbeDir(context) {
|
|
50
|
+
return path.join(context.sessionDir, 'mutation-probes');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mutationProbeFile(context, key) {
|
|
54
|
+
if (!MUTATION_PROBE_KEY.test(String(key || ''))) throw new Error('invalid mutation probe identity');
|
|
55
|
+
return path.join(mutationProbeDir(context), key + '.json');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withMutationProbeLock(context, callback) {
|
|
59
|
+
fs.mkdirSync(context.sessionDir, { recursive: true });
|
|
60
|
+
const lockDir = path.join(context.sessionDir, '.mutation-probes.lock');
|
|
61
|
+
let acquired = false;
|
|
62
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(lockDir);
|
|
65
|
+
acquired = true;
|
|
66
|
+
break;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code !== 'EEXIST') throw error;
|
|
69
|
+
try {
|
|
70
|
+
const stat = fs.statSync(lockDir);
|
|
71
|
+
if (Date.now() - stat.mtimeMs > 30000) {
|
|
72
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
} catch (_) { /* retry below */ }
|
|
76
|
+
Atomics.wait(MUTATION_PROBE_WAIT, 0, 0, 10);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!acquired) throw new Error('cannot acquire mutation probe lock');
|
|
80
|
+
try { return callback(); }
|
|
81
|
+
finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mutationProbeReservations(context) {
|
|
85
|
+
const dir = mutationProbeDir(context);
|
|
86
|
+
let files = [];
|
|
87
|
+
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
88
|
+
catch (_) { return 0; }
|
|
89
|
+
let count = 0;
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const probe = readJson(path.join(dir, file));
|
|
92
|
+
if (probe?.schemaVersion === 1 && probe.reserveIteration === true) count++;
|
|
93
|
+
}
|
|
94
|
+
return count;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function refreshWorkflowForProbe(context) {
|
|
98
|
+
const latest = readJson(context.file);
|
|
99
|
+
if (!latest || latest.sessionId !== context.sessionId) throw new Error('cannot refresh workflow for mutation probe');
|
|
100
|
+
context.workflow = latest;
|
|
101
|
+
return latest;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertIterationCapacity(context, evidence) {
|
|
105
|
+
const { workflow } = context;
|
|
106
|
+
if (!workflow) throw budgetError('no workflow available for iteration capacity');
|
|
107
|
+
const budget = ensureWorkflowBudget(workflow);
|
|
108
|
+
if (budget.state !== 'active') throw budgetError(`workflow budget is ${budget.state}: ${budget.reasonCode || 'unavailable'}`);
|
|
109
|
+
const limit = budget.limits.maxIterations;
|
|
110
|
+
if (limit === null) return { applicable: false, value: budget.counters.iterations, reserved: 0 };
|
|
111
|
+
const reserved = mutationProbeReservations(context);
|
|
112
|
+
if (budget.counters.iterations + reserved >= limit) {
|
|
113
|
+
return exhaustBudget(context, 'iteration-budget-exhausted', evidence || 'untrusted-shell-capacity');
|
|
114
|
+
}
|
|
115
|
+
return { applicable: true, value: budget.counters.iterations, reserved, limit };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function registerMutationProbe(context, key, fingerprint, reserveIteration) {
|
|
119
|
+
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
120
|
+
throw new Error('invalid mutation probe identity');
|
|
121
|
+
}
|
|
122
|
+
return withMutationProbeLock(context, () => {
|
|
123
|
+
refreshWorkflowForProbe(context);
|
|
124
|
+
if (reserveIteration) assertIterationCapacity(context, 'shell:untrusted');
|
|
125
|
+
const dir = mutationProbeDir(context);
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
const file = mutationProbeFile(context, key);
|
|
128
|
+
if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
|
|
129
|
+
const probe = {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
fingerprint,
|
|
132
|
+
reserveIteration: Boolean(reserveIteration),
|
|
133
|
+
observedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
atomicWriteJson(file, probe);
|
|
136
|
+
return probe;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function peekMutationProbe(context, key) {
|
|
141
|
+
return readJson(mutationProbeFile(context, key));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function settleMutationProbe(context, key, afterFingerprint, tool) {
|
|
145
|
+
if (!MUTATION_PROBE_KEY.test(String(afterFingerprint || ''))) throw new Error('invalid mutation probe fingerprint');
|
|
146
|
+
return withMutationProbeLock(context, () => {
|
|
147
|
+
const file = mutationProbeFile(context, key);
|
|
148
|
+
const probe = readJson(file);
|
|
149
|
+
if (!probe || probe.schemaVersion !== 1) return null;
|
|
150
|
+
refreshWorkflowForProbe(context);
|
|
151
|
+
fs.unlinkSync(file);
|
|
152
|
+
const changed = afterFingerprint !== probe.fingerprint;
|
|
153
|
+
if (changed) {
|
|
154
|
+
context.workflow.lastMutationAt = Date.now();
|
|
155
|
+
if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
|
|
156
|
+
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
|
|
157
|
+
} else {
|
|
158
|
+
saveWorkflow(context);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { changed, probe };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function discardMutationProbe(context, key) {
|
|
166
|
+
return withMutationProbeLock(context, () => {
|
|
167
|
+
const file = mutationProbeFile(context, key);
|
|
168
|
+
if (!fs.existsSync(file)) return false;
|
|
169
|
+
fs.unlinkSync(file);
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearMutationProbes(context) {
|
|
175
|
+
return withMutationProbeLock(context, () => {
|
|
176
|
+
fs.rmSync(mutationProbeDir(context), { recursive: true, force: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
46
180
|
function budgetLimits(workflow) {
|
|
47
181
|
const limits = workflow?.workflowPlan?.limits || {};
|
|
48
182
|
return {
|
|
@@ -123,7 +257,8 @@ function recordBudgetEvent(context, type, options = {}) {
|
|
|
123
257
|
const consume = (counter, limitName, reasonCode) => {
|
|
124
258
|
const limit = budget.limits[limitName];
|
|
125
259
|
if (limit === null) return { applicable: false, value: budget.counters[counter] };
|
|
126
|
-
|
|
260
|
+
const reserved = type === 'iteration' ? mutationProbeReservations(context) : 0;
|
|
261
|
+
if (budget.counters[counter] + reserved >= limit) return exhaustBudget(context, reasonCode, evidence);
|
|
127
262
|
budget.counters[counter]++;
|
|
128
263
|
appendBudgetEvent(budget, { type, counter, value: budget.counters[counter], limit, evidence, observedAt: now });
|
|
129
264
|
persistBudget(context);
|
|
@@ -175,6 +310,7 @@ function resetWorkflowBudget(context, evidence) {
|
|
|
175
310
|
evidence: String(evidence).trim(),
|
|
176
311
|
resetAt: now,
|
|
177
312
|
}].slice(-16);
|
|
313
|
+
clearMutationProbes(context);
|
|
178
314
|
workflow.budget = {
|
|
179
315
|
schemaVersion: 1,
|
|
180
316
|
epoch: previous.epoch + 1,
|
|
@@ -260,4 +396,4 @@ function readHookInput(decide) {
|
|
|
260
396
|
process.stdin.on('error', finish);
|
|
261
397
|
}
|
|
262
398
|
|
|
263
|
-
module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
|
|
399
|
+
module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, mutationProbeReservations, assertIterationCapacity, registerMutationProbe, peekMutationProbe, settleMutationProbe, discardMutationProbe, clearMutationProbes, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
|
|
5
|
+
const { loadWorkflow, saveWorkflow, peekMutationProbe, settleMutationProbe } = require('./lib/workflow-runtime');
|
|
6
|
+
const { cwdOf, shellProbeKey, workspaceFingerprint } = require('./lib/workflow-isolation');
|
|
5
7
|
const { observeTool } = require('./lib/telemetry');
|
|
6
8
|
|
|
7
9
|
// Commands that count as "verification ran" for the Stop gate
|
|
@@ -28,7 +30,31 @@ process.stdin.on('end', () => {
|
|
|
28
30
|
}
|
|
29
31
|
});
|
|
30
32
|
|
|
33
|
+
function observeWorkspaceMutation(payload, root) {
|
|
34
|
+
const toolName = payload && (payload.tool_name || payload.tool);
|
|
35
|
+
if (toolName !== 'Bash' && toolName !== 'PowerShell' && toolName !== 'exec_command') return false;
|
|
36
|
+
const context = loadWorkflow(payload);
|
|
37
|
+
if (!context.workflow || context.workflow.state === 'deferred') return false;
|
|
38
|
+
const cwd = cwdOf(payload, root);
|
|
39
|
+
const probeKey = shellProbeKey(payload, cwd);
|
|
40
|
+
if (!peekMutationProbe(context, probeKey)) return false;
|
|
41
|
+
let after;
|
|
42
|
+
try {
|
|
43
|
+
after = workspaceFingerprint(cwd);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
context.workflow.state = 'blocked';
|
|
46
|
+
context.workflow.blockReason = 'mutation-observation-failed';
|
|
47
|
+
saveWorkflow(context);
|
|
48
|
+
const critical = new Error('cannot verify whether shell execution changed workspace content');
|
|
49
|
+
critical.code = 'HARNESS_MUTATION_OBSERVATION';
|
|
50
|
+
throw critical;
|
|
51
|
+
}
|
|
52
|
+
const result = settleMutationProbe(context, probeKey, after, toolName);
|
|
53
|
+
return Boolean(result && result.changed);
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
function processState(payload) {
|
|
57
|
+
let exitCodeForHook = 0;
|
|
32
58
|
try {
|
|
33
59
|
const sessionId = payload && (payload.session_id || payload.sessionId);
|
|
34
60
|
const root = getWorkspaceRoot(payload);
|
|
@@ -48,12 +74,16 @@ function processState(payload) {
|
|
|
48
74
|
// it's actually a number; fall back to a stderr signal otherwise.
|
|
49
75
|
const toolResponse = payload.tool_response || {};
|
|
50
76
|
const stdout = toolResponse.stdout ?? payload.stdout ?? '';
|
|
51
|
-
const stderr = toolResponse.stderr ?? payload.stderr ?? '';
|
|
77
|
+
const stderr = toolResponse.stderr ?? payload.stderr ?? payload.error ?? '';
|
|
52
78
|
const rawExitCode = toolResponse.exitCode ?? toolResponse.exit_code ?? payload.exitCode;
|
|
53
79
|
const exitCode = typeof rawExitCode === 'number' ? rawExitCode : undefined;
|
|
54
80
|
const stderrSignal = typeof stderr === 'string' && stderr.trim().length > 0;
|
|
55
|
-
const
|
|
81
|
+
const hookEvent = payload.hook_event_name || payload.hookEventName || '';
|
|
82
|
+
const isFailed = hookEvent === 'PostToolUseFailure' ||
|
|
83
|
+
(exitCode !== undefined && exitCode !== 0) ||
|
|
84
|
+
(exitCode === undefined && stderrSignal);
|
|
56
85
|
const toolName = payload.tool_name || payload.tool || 'command';
|
|
86
|
+
const observedMutation = observeWorkspaceMutation(payload, root);
|
|
57
87
|
|
|
58
88
|
if (isFailed) {
|
|
59
89
|
// Truncate output to avoid state bloat
|
|
@@ -78,9 +108,10 @@ function processState(payload) {
|
|
|
78
108
|
|
|
79
109
|
// Milestones for the Stop gate: when did the last mutation happen, and
|
|
80
110
|
// has any verification-ish command succeeded since.
|
|
81
|
-
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch') {
|
|
111
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'apply_patch' || observedMutation) {
|
|
82
112
|
state.lastEditAt = Date.now();
|
|
83
|
-
}
|
|
113
|
+
}
|
|
114
|
+
if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
|
|
84
115
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
85
116
|
if (VERIFY_COMMAND_RE.test(command)) {
|
|
86
117
|
state.lastVerifyAt = Date.now();
|
|
@@ -100,7 +131,11 @@ function processState(payload) {
|
|
|
100
131
|
}
|
|
101
132
|
}
|
|
102
133
|
} catch (err) {
|
|
103
|
-
|
|
134
|
+
if (err && (err.code === 'HARNESS_MUTATION_OBSERVATION' || err.code === 'HARNESS_WORKFLOW_BUDGET')) {
|
|
135
|
+
console.error('[State Persist] ' + err.message);
|
|
136
|
+
exitCodeForHook = 2;
|
|
137
|
+
}
|
|
138
|
+
// Non-critical persistence/telemetry failures remain fail-open.
|
|
104
139
|
}
|
|
105
|
-
process.exit(
|
|
140
|
+
process.exit(exitCodeForHook);
|
|
106
141
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
-
const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
|
|
7
|
-
const { key, classifyShell, cwdOf, commandOf, mutationPaths, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
|
|
6
|
+
const { loadWorkflow, saveWorkflow, isMajorWorkflow, matchingRun, registerMutationProbe, recordBudgetEvent, readHookInput, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
|
|
7
|
+
const { key, classifyShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
|
|
8
8
|
|
|
9
9
|
const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
|
|
10
10
|
const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
|
|
@@ -27,8 +27,8 @@ function decide(payload) {
|
|
|
27
27
|
const cwd = cwdOf(payload, root);
|
|
28
28
|
const command = commandOf(payload);
|
|
29
29
|
const commandClass = SHELL.has(tool) ? classifyShell(command) : 'direct-mutation';
|
|
30
|
-
if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
|
|
31
30
|
if (SHELL.has(tool) && isController(command, cwd)) return;
|
|
31
|
+
if (commandClass === 'read-only' || commandClass === 'worktree-setup') return;
|
|
32
32
|
// Only the stage specification is allowed before run entry. Other runtime
|
|
33
33
|
// state, including workflow-run.json, cannot use this bootstrap exception.
|
|
34
34
|
const targets = DIRECT.has(tool) ? mutationPaths(payload) : [];
|
|
@@ -47,11 +47,22 @@ function decide(payload) {
|
|
|
47
47
|
if (String(workflow.strategy || '').startsWith('fable-') && !matchingRun(context)) {
|
|
48
48
|
throw new Error('selected Fable workflow has no correlated run. Write workflow-stages.json at the displayed session path, then use workflow-disposition.js start.');
|
|
49
49
|
}
|
|
50
|
-
if (
|
|
51
|
-
|
|
50
|
+
if (DIRECT.has(tool)) {
|
|
51
|
+
if (workflow.strategy === 'iterative-single') {
|
|
52
|
+
recordBudgetEvent(context, 'iteration', { evidence: `${tool}:direct-mutation` });
|
|
53
|
+
}
|
|
54
|
+
workflow.lastMutationAt = Date.now();
|
|
55
|
+
saveWorkflow(context);
|
|
56
|
+
return;
|
|
52
57
|
}
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
|
|
59
|
+
// Shell safety and shell mutation accounting are intentionally separate.
|
|
60
|
+
// `untrusted` means "not proven read-only", not "a mutation occurred".
|
|
61
|
+
// Capture an opaque pre-execution workspace fingerprint; the post-tool
|
|
62
|
+
// observer consumes an iteration only when workspace content actually changed.
|
|
63
|
+
const fingerprint = workspaceFingerprint(cwd);
|
|
64
|
+
const probeKey = shellProbeKey(payload, cwd);
|
|
65
|
+
registerMutationProbe(context, probeKey, fingerprint, workflow.strategy === 'iterative-single');
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
readHookInput(decide);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { loadWorkflow, discardMutationProbe, readHookInput } = require('./lib/workflow-runtime');
|
|
5
|
+
const { cwdOf, shellProbeKey } = require('./lib/workflow-isolation');
|
|
6
|
+
|
|
7
|
+
function discardDenied(payload) {
|
|
8
|
+
if (!payload) return;
|
|
9
|
+
const tool = payload.tool_name || payload.tool;
|
|
10
|
+
if (tool !== 'Bash' && tool !== 'PowerShell' && tool !== 'exec_command') return;
|
|
11
|
+
const context = loadWorkflow(payload);
|
|
12
|
+
if (!context.workflow || context.workflow.state === 'deferred') return;
|
|
13
|
+
const cwd = cwdOf(payload, context.root);
|
|
14
|
+
discardMutationProbe(context, shellProbeKey(payload, cwd));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
readHookInput(discardDenied);
|
package/package.json
CHANGED
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"command": "node \"${PLUGIN_ROOT}/hooks/scripts/state-persist.js\"",
|
|
143
143
|
"commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\state-persist.js\"",
|
|
144
144
|
"timeout": 15,
|
|
145
|
-
"statusMessage": "Saving Harness
|
|
145
|
+
"statusMessage": "Saving Harness state and observed mutation"
|
|
146
146
|
}
|
|
147
147
|
]
|
|
148
148
|
},
|
|
@@ -1,13 +1,89 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const { spawnSync } = require('child_process');
|
|
6
7
|
|
|
7
|
-
function
|
|
8
|
-
const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000 });
|
|
8
|
+
function gitRaw(cwd, args) {
|
|
9
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
|
|
9
10
|
if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
|
|
10
|
-
return result.stdout
|
|
11
|
+
return result.stdout;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function git(cwd, args) {
|
|
15
|
+
return gitRaw(cwd, args).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hashWorktreePath(root, relative) {
|
|
19
|
+
const target = path.join(root, relative);
|
|
20
|
+
let stat;
|
|
21
|
+
try { stat = fs.lstatSync(target); }
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT') return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (stat.isDirectory()) {
|
|
27
|
+
const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
|
|
28
|
+
return { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' };
|
|
29
|
+
}
|
|
30
|
+
const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
|
|
31
|
+
const result = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
|
|
32
|
+
cwd: root, encoding: 'utf8', windowsHide: true, timeout: 5000,
|
|
33
|
+
});
|
|
34
|
+
if (result.status !== 0) throw new Error('cannot fingerprint workspace content');
|
|
35
|
+
return { mode, oid: result.stdout.trim() };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function workspaceFingerprint(cwd) {
|
|
39
|
+
const root = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
|
|
40
|
+
const entries = new Map();
|
|
41
|
+
for (const record of gitRaw(root, ['ls-files', '-s', '-z']).split('\0')) {
|
|
42
|
+
if (!record) continue;
|
|
43
|
+
const tab = record.indexOf('\t');
|
|
44
|
+
if (tab < 0) throw new Error('cannot fingerprint workspace index');
|
|
45
|
+
const meta = record.slice(0, tab).trim().split(/\s+/);
|
|
46
|
+
const relative = record.slice(tab + 1);
|
|
47
|
+
const mode = meta[0];
|
|
48
|
+
const oid = meta[1];
|
|
49
|
+
entries.set(relative, { relative, mode, oid });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Substitute current worktree content for tracked paths that differ from the
|
|
53
|
+
// index. This keeps the fingerprint stable across git add / git commit when
|
|
54
|
+
// visible workspace content did not change.
|
|
55
|
+
for (const relative of gitRaw(root, ['diff-files', '--name-only', '-z']).split('\0').filter(Boolean)) {
|
|
56
|
+
const value = hashWorktreePath(root, relative);
|
|
57
|
+
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
58
|
+
else entries.delete(relative);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const relative of gitRaw(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
|
|
62
|
+
const value = hashWorktreePath(root, relative);
|
|
63
|
+
entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const digest = crypto.createHash('sha256');
|
|
67
|
+
digest.update('harness-workspace-fingerprint-v1\0');
|
|
68
|
+
for (const entry of [...entries.values()].sort((a, b) => a.relative.localeCompare(b.relative))) {
|
|
69
|
+
digest.update(entry.relative); digest.update('\0');
|
|
70
|
+
digest.update(entry.mode); digest.update('\0');
|
|
71
|
+
digest.update(entry.oid); digest.update('\0');
|
|
72
|
+
}
|
|
73
|
+
return digest.digest('hex');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shellProbeKey(payload, cwd) {
|
|
77
|
+
const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
|
|
78
|
+
const tool = String(payload?.tool_name || payload?.tool || '');
|
|
79
|
+
const command = commandOf(payload);
|
|
80
|
+
return crypto.createHash('sha256')
|
|
81
|
+
.update('harness-shell-probe-v2\0')
|
|
82
|
+
.update(toolUseId || 'no-tool-use-id').update('\0')
|
|
83
|
+
.update(tool).update('\0')
|
|
84
|
+
.update(key(cwd)).update('\0')
|
|
85
|
+
.update(command)
|
|
86
|
+
.digest('hex');
|
|
11
87
|
}
|
|
12
88
|
|
|
13
89
|
function canonical(value) {
|
|
@@ -43,13 +119,12 @@ function linkedWorktree(cwd, boundRoot) {
|
|
|
43
119
|
return worktrees.some(field => field.startsWith('worktree ') && key(field.slice(9)) === key(top)) ? top : null;
|
|
44
120
|
}
|
|
45
121
|
|
|
46
|
-
// A conservative read subset, not a general shell sandbox.
|
|
47
|
-
// `||`, `|`) and the `2>&1`/`1>&2` fd-duplication redirect
|
|
48
|
-
// when every resulting segment independently matches the
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const SAFE_FD_REDIRECT_RE = /[12]>&[12]/g;
|
|
122
|
+
// A conservative read subset, not a general shell sandbox. Top-level chaining
|
|
123
|
+
// (`;`, `&&`, `||`, `|`) and the `2>&1`/`1>&2` fd-duplication redirect
|
|
124
|
+
// are permitted only when every resulting segment independently matches the
|
|
125
|
+
// trusted read-only set below. The scanner is deliberately lexical rather than
|
|
126
|
+
// a raw regex split so quoted/escaped separator characters remain arguments.
|
|
127
|
+
// Unsupported shell syntax still fails closed.
|
|
53
128
|
const DANGEROUS_FLAG_RE = /\s(?:--(?:output|ext-diff|textconv|exec|pre|pre-glob|pager|open|batch|filters)|-[xoO])(?:\b|=)|\s-(?:exec|execdir|delete|fprint|fprintf)\b/i;
|
|
54
129
|
const READ_ONLY_PREFIX_RE = /^(?:git\s+(?:status|rev-parse|diff|log|show|ls-files|check-ignore)(?:\s|$)|git\s+branch\s+--show-current$|git\s+worktree\s+list(?:\s|$)|gh\s+(?:issue|pr)\s+(?:view|list)(?:\s|$)|gh\s+repo\s+view(?:\s|$)|gh\s+auth\s+status(?:\s|$)|(?:pwd|ls|dir|cat|type|head|tail|wc|stat|rg|grep|echo|Get-Location|Get-ChildItem|Get-Content|Select-String|Test-Path)(?:\s|$))/i;
|
|
55
130
|
|
|
@@ -57,20 +132,102 @@ function isReadOnlySegment(segment) {
|
|
|
57
132
|
return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
|
|
58
133
|
}
|
|
59
134
|
|
|
135
|
+
function splitShellSegments(text) {
|
|
136
|
+
const segments = [];
|
|
137
|
+
let current = '';
|
|
138
|
+
let quote = null;
|
|
139
|
+
|
|
140
|
+
const push = () => {
|
|
141
|
+
const segment = current.trim();
|
|
142
|
+
if (!segment) return false;
|
|
143
|
+
segments.push(segment);
|
|
144
|
+
current = '';
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < text.length; i++) {
|
|
149
|
+
const ch = text[i];
|
|
150
|
+
|
|
151
|
+
if (quote === "'") {
|
|
152
|
+
current += ch;
|
|
153
|
+
if (ch === "'") quote = null;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (quote === '"') {
|
|
158
|
+
if (ch === '$' || ch === '`') return null;
|
|
159
|
+
current += ch;
|
|
160
|
+
if (ch === '"') {
|
|
161
|
+
quote = null;
|
|
162
|
+
} else if (ch === '\\') {
|
|
163
|
+
if (i + 1 >= text.length) return null;
|
|
164
|
+
current += text[++i];
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (ch === "'" || ch === '"') {
|
|
170
|
+
quote = ch;
|
|
171
|
+
current += ch;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (ch === '\\') {
|
|
176
|
+
if (i + 1 >= text.length) return null;
|
|
177
|
+
current += ch + text[++i];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ch === '$' || ch === '`' || ch === '(' || ch === ')' || ch === '{' || ch === '}' ||
|
|
182
|
+
ch === '\r' || ch === '\n' || ch === '\x00') return null;
|
|
183
|
+
|
|
184
|
+
// Only treat fd duplication as syntax at a token boundary. Quoted text such
|
|
185
|
+
// as "2>&1" was handled above and remains literal argument content.
|
|
186
|
+
if ((ch === '1' || ch === '2') && text[i + 1] === '>' && text[i + 2] === '&' &&
|
|
187
|
+
(text[i + 3] === '1' || text[i + 3] === '2') &&
|
|
188
|
+
(i === 0 || /\s/.test(text[i - 1])) &&
|
|
189
|
+
(i + 4 === text.length || /[\s;|&]/.test(text[i + 4]))) {
|
|
190
|
+
current += ' ';
|
|
191
|
+
i += 3;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (ch === '>' || ch === '<') return null;
|
|
196
|
+
|
|
197
|
+
if (ch === ';') {
|
|
198
|
+
if (!push()) return null;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ch === '|') {
|
|
203
|
+
if (!push()) return null;
|
|
204
|
+
if (text[i + 1] === '|') i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ch === '&') {
|
|
209
|
+
if (text[i + 1] !== '&' || !push()) return null;
|
|
210
|
+
i++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
current += ch;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (quote || !push()) return null;
|
|
218
|
+
return segments;
|
|
219
|
+
}
|
|
220
|
+
|
|
60
221
|
function classifyShell(command) {
|
|
61
222
|
const text = String(command || '').trim();
|
|
62
|
-
if (!text
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const withoutSafeRedirects = text.replace(SAFE_FD_REDIRECT_RE, ' ');
|
|
66
|
-
if (/&/.test(withoutSafeRedirects.replace(/&&/g, ''))) return 'mutation-or-unknown';
|
|
67
|
-
if (/[<>]/.test(withoutSafeRedirects)) return 'mutation-or-unknown';
|
|
68
|
-
const segments = text.split(SHELL_SEPARATOR_RE).map(segment => segment.replace(SAFE_FD_REDIRECT_RE, '').trim());
|
|
223
|
+
if (!text) return 'untrusted';
|
|
224
|
+
const segments = splitShellSegments(text);
|
|
225
|
+
if (!segments) return 'untrusted';
|
|
69
226
|
if (segments.length === 1) {
|
|
70
227
|
if (/^git\s+worktree\s+add\s+/i.test(segments[0]) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(segments[0])) return 'worktree-setup';
|
|
71
228
|
return isReadOnlySegment(segments[0]) ? 'read-only' : 'mutation-or-unknown';
|
|
72
229
|
}
|
|
73
|
-
return segments.every(
|
|
230
|
+
return segments.every(isReadOnlySegment) ? 'read-only' : 'mutation-or-unknown';
|
|
74
231
|
}
|
|
75
232
|
|
|
76
233
|
function inputOf(payload) { return payload?.tool_input ?? payload?.toolInput ?? payload?.input ?? {}; }
|
|
@@ -117,4 +274,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
117
274
|
}
|
|
118
275
|
}
|
|
119
276
|
|
|
120
|
-
module.exports = { canonical, key, within, linkedWorktree, classifyShell, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };
|
|
277
|
+
module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };
|