harness-everything 0.11.5 → 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 +19 -0
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +175 -8
- package/hooks/hooks.json +25 -1
- package/hooks/scripts/lib/workflow-isolation.js +82 -6
- 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 +82 -6
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "harness-everything",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
13
|
-
"version": "0.11.
|
|
13
|
+
"version": "0.11.6",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Miya Daniel"
|
|
16
16
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
3
|
"description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.11.6](https://github.com/dyphn1/Harness-everything/compare/v0.11.5...v0.11.6) (2026-09-18)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **workflow:** avoid workflow rewrite after probe registration ([b9f2e06](https://github.com/dyphn1/Harness-everything/commit/b9f2e065114d70e927b6f75e7c0f7899d9e8392f))
|
|
10
|
+
* **workflow:** correlate mutation probes by tool-use identity ([86cd1a1](https://github.com/dyphn1/Harness-everything/commit/86cd1a1c6eaef6518936160b0c35f94a3e652cae))
|
|
11
|
+
* **workflow:** defer shell mutation accounting to observer ([c543fd0](https://github.com/dyphn1/Harness-everything/commit/c543fd0c41e18f05c21235ce4ffcf28864200940))
|
|
12
|
+
* **workflow:** discard denied shell mutation probes ([7a8757c](https://github.com/dyphn1/Harness-everything/commit/7a8757cab821e524ccdd17e416e013bfbadc4f39))
|
|
13
|
+
* **workflow:** fingerprint observed workspace effects ([9d8e316](https://github.com/dyphn1/Harness-everything/commit/9d8e316eb97ec7bbcd0e46ceb51491af90e57e9f))
|
|
14
|
+
* **workflow:** fingerprint visible content independent of staging ([a1f370e](https://github.com/dyphn1/Harness-everything/commit/a1f370e64a97b04bee4d8df23e3ffdc7552377b8))
|
|
15
|
+
* **workflow:** isolate mutation probes per tool call ([70d6052](https://github.com/dyphn1/Harness-everything/commit/70d6052ef02fce1125da9a20a14a4ad65d09ba93))
|
|
16
|
+
* **workflow:** make mutation observation single-handler ordered ([ea166e1](https://github.com/dyphn1/Harness-everything/commit/ea166e1a94cdd1725cd7242feb5b0716a4dce8b8))
|
|
17
|
+
* **workflow:** normalize deleted paths in fingerprint ([e5019da](https://github.com/dyphn1/Harness-everything/commit/e5019daee870e806d77d92c741a9624cd6c9bfe0))
|
|
18
|
+
* **workflow:** observe shell effects before state persistence ([5271655](https://github.com/dyphn1/Harness-everything/commit/5271655627344eb71ffeb8dd36b007709862b9d0))
|
|
19
|
+
* **workflow:** observe shell workspace mutations ([c547b39](https://github.com/dyphn1/Harness-everything/commit/c547b396bf2e3829b83ed03abeca594b7391f542))
|
|
20
|
+
* **workflow:** remove parallel standalone mutation observer ([20027f6](https://github.com/dyphn1/Harness-everything/commit/20027f68f5b63719ea7a96a524f49c7103586644))
|
|
21
|
+
* **workflow:** reserve shell mutation probes ([e7273f6](https://github.com/dyphn1/Harness-everything/commit/e7273f66010d5b0c740b87b8dbc020c8de616fe7))
|
|
22
|
+
* **workflow:** settle mutation probes inside state persistence ([88feaf4](https://github.com/dyphn1/Harness-everything/commit/88feaf4ee3accbd8504b745fd4e79e548c31bd6d))
|
|
23
|
+
|
|
5
24
|
## [0.11.5](https://github.com/dyphn1/Harness-everything/compare/v0.11.4...v0.11.5) (2026-09-18)
|
|
6
25
|
|
|
7
26
|
### Bug Fixes
|
|
@@ -35,6 +35,46 @@ function git(args) {
|
|
|
35
35
|
}
|
|
36
36
|
const route = prompt => node('harness-everything/scripts/kernel-router.js', { ...payload, prompt });
|
|
37
37
|
const gate = (tool, input, cwd = linked) => node('hooks/scripts/workflow-gate.js', { ...payload, cwd, tool_name: tool, tool_input: input });
|
|
38
|
+
let toolUseSeq = 0;
|
|
39
|
+
function beginShell(command, cwd = repo) {
|
|
40
|
+
const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
|
|
41
|
+
const input = { ...payload, cwd, tool_name: 'Bash', tool_use_id: toolUseId, tool_input: { command } };
|
|
42
|
+
return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
|
|
43
|
+
}
|
|
44
|
+
function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
|
|
45
|
+
const input = {
|
|
46
|
+
...payload,
|
|
47
|
+
cwd: call.cwd,
|
|
48
|
+
hook_event_name: hookEvent,
|
|
49
|
+
tool_name: 'Bash',
|
|
50
|
+
tool_use_id: call.toolUseId,
|
|
51
|
+
tool_input: { command: call.command },
|
|
52
|
+
};
|
|
53
|
+
if (hookEvent === 'PostToolUseFailure') {
|
|
54
|
+
input.error = response.error || 'Exit code 1\nfixture failure';
|
|
55
|
+
input.is_interrupt = Boolean(response.is_interrupt);
|
|
56
|
+
} else {
|
|
57
|
+
input.tool_response = response;
|
|
58
|
+
}
|
|
59
|
+
return node('hooks/scripts/state-persist.js', input);
|
|
60
|
+
}
|
|
61
|
+
function denyShell(call) {
|
|
62
|
+
return node('hooks/scripts/workflow-mutation-denied.js', {
|
|
63
|
+
...payload,
|
|
64
|
+
cwd: call.cwd,
|
|
65
|
+
hook_event_name: 'PermissionDenied',
|
|
66
|
+
permission_mode: 'auto',
|
|
67
|
+
tool_name: 'Bash',
|
|
68
|
+
tool_use_id: call.toolUseId,
|
|
69
|
+
tool_input: { command: call.command },
|
|
70
|
+
reason: 'Blocked by classifier',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const probeDir = path.join(sessionDir, 'mutation-probes');
|
|
74
|
+
const probeFiles = () => {
|
|
75
|
+
try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
|
|
76
|
+
catch (_) { return []; }
|
|
77
|
+
};
|
|
38
78
|
const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
|
|
39
79
|
const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
|
|
40
80
|
const stages = [
|
|
@@ -183,10 +223,132 @@ try {
|
|
|
183
223
|
check((afterReadOnly.lastMutationAt || 0) === readOnlyMutationAt,
|
|
184
224
|
'read-only shell composition does not advance lastMutationAt');
|
|
185
225
|
|
|
186
|
-
|
|
187
|
-
|
|
226
|
+
// #159: safety classification is not mutation evidence. Commands that are
|
|
227
|
+
// not proven read-only are admitted under an opaque workspace probe while
|
|
228
|
+
// capacity remains, then accounted only if the visible workspace changed.
|
|
229
|
+
const untrustedReads = [
|
|
230
|
+
'git fetch origin --quiet 2>&1; git status; git log --oneline -1',
|
|
231
|
+
'git remote -v',
|
|
232
|
+
'gh run view 1 --json status',
|
|
233
|
+
'node -e "console.log(42)"',
|
|
234
|
+
];
|
|
235
|
+
for (const command of untrustedReads) {
|
|
236
|
+
const before = read(file);
|
|
237
|
+
const call = beginShell(command);
|
|
238
|
+
check(call.result.status === 0, 'untrusted read/query is admitted under observation: ' + command);
|
|
239
|
+
const probes = probeFiles();
|
|
240
|
+
check(probes.length === 1 && !fs.readFileSync(path.join(probeDir, probes[0]), 'utf8').includes(command),
|
|
241
|
+
'mutation probe persists only opaque identity, not raw command text');
|
|
242
|
+
check(persistShell(call).status === 0, 'no-effect shell probe resolves through state-persist: ' + command);
|
|
243
|
+
check(probeFiles().length === 0, 'completed shell call removes its mutation probe');
|
|
244
|
+
const after = read(file);
|
|
245
|
+
check((after.budget?.counters?.iterations || 0) === (before.budget?.counters?.iterations || 0),
|
|
246
|
+
'no-effect untrusted shell command consumes no iteration');
|
|
247
|
+
check((after.lastMutationAt || 0) === (before.lastMutationAt || 0),
|
|
248
|
+
'no-effect untrusted shell command does not advance lastMutationAt');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const deniedCall = beginShell('node denied-by-auto-mode.js');
|
|
252
|
+
check(deniedCall.result.status === 0 && probeFiles().length === 1,
|
|
253
|
+
'auto-mode candidate reserves mutation capacity before permission decision');
|
|
254
|
+
check(denyShell(deniedCall).status === 0 && probeFiles().length === 0,
|
|
255
|
+
'PermissionDenied releases the exact tool-use mutation probe');
|
|
256
|
+
const parallelA = beginShell('node repeated-read-only-wrapper.js');
|
|
257
|
+
const parallelB = beginShell('node repeated-read-only-wrapper.js');
|
|
258
|
+
check(parallelA.result.status === 0 && parallelB.result.status === 0 && probeFiles().length === 2,
|
|
259
|
+
'identical concurrent shell commands receive distinct tool-use probes');
|
|
260
|
+
check(denyShell(parallelA).status === 0 && persistShell(parallelB).status === 0 && probeFiles().length === 0,
|
|
261
|
+
'parallel tool-use probes settle independently without reservation leakage');
|
|
262
|
+
|
|
263
|
+
const metadataFetch = 'git fetch . HEAD:refs/remotes/origin/harness-probe';
|
|
264
|
+
const beforeMetadataFetch = read(file);
|
|
265
|
+
const metadataFetchCall = beginShell(metadataFetch);
|
|
266
|
+
check(metadataFetchCall.result.status === 0,
|
|
267
|
+
'metadata-only git fetch is admitted under observation');
|
|
268
|
+
git(['fetch', '.', 'HEAD:refs/remotes/origin/harness-probe']);
|
|
269
|
+
check(persistShell(metadataFetchCall).status === 0, 'real metadata-only git fetch probe resolves');
|
|
270
|
+
check((read(file).budget?.counters?.iterations || 0) === (beforeMetadataFetch.budget?.counters?.iterations || 0) &&
|
|
271
|
+
(read(file).lastMutationAt || 0) === (beforeMetadataFetch.lastMutationAt || 0),
|
|
272
|
+
'real git fetch changes Git metadata without consuming a code iteration');
|
|
273
|
+
|
|
274
|
+
let observedIterations = read(file).budget?.counters?.iterations || 0;
|
|
275
|
+
const mutateTracked = 'node mutate-tracked.js';
|
|
276
|
+
const mutateTrackedCall = beginShell(mutateTracked);
|
|
277
|
+
check(mutateTrackedCall.result.status === 0, 'unknown tracked-file mutator is admitted under observation');
|
|
278
|
+
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed once\n');
|
|
279
|
+
check(persistShell(mutateTrackedCall).status === 0, 'tracked-file mutation is observed');
|
|
280
|
+
check(read(file).budget.counters.iterations === ++observedIterations, 'tracked-file mutation consumes exactly one iteration');
|
|
281
|
+
const firstObservedAt = read(file).lastMutationAt;
|
|
282
|
+
|
|
283
|
+
const mutateDirty = 'node mutate-dirty-again.js';
|
|
284
|
+
const mutateDirtyCall = beginShell(mutateDirty);
|
|
285
|
+
check(mutateDirtyCall.result.status === 0, 'unknown already-dirty mutator is admitted under observation');
|
|
286
|
+
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed twice\n');
|
|
287
|
+
check(persistShell(mutateDirtyCall).status === 0, 'already-dirty content change is observed');
|
|
288
|
+
check(read(file).budget.counters.iterations === ++observedIterations && read(file).lastMutationAt >= firstObservedAt,
|
|
289
|
+
'already-dirty content change consumes one new iteration');
|
|
290
|
+
|
|
291
|
+
const createUntracked = 'node create-untracked.js';
|
|
292
|
+
const createUntrackedCall = beginShell(createUntracked);
|
|
293
|
+
check(createUntrackedCall.result.status === 0, 'unknown untracked-file mutator is admitted under observation');
|
|
294
|
+
fs.writeFileSync(path.join(repo, 'observed-untracked.txt'), 'one\n');
|
|
295
|
+
check(persistShell(createUntrackedCall).status === 0, 'untracked-file creation is observed');
|
|
296
|
+
check(read(file).budget.counters.iterations === ++observedIterations, 'untracked-file creation consumes exactly one iteration');
|
|
297
|
+
|
|
298
|
+
const failedMutation = 'node fail-after-write.js';
|
|
299
|
+
const failedMutationCall = beginShell(failedMutation);
|
|
300
|
+
check(failedMutationCall.result.status === 0, 'failing shell mutator is admitted under observation');
|
|
301
|
+
fs.writeFileSync(path.join(repo, 'observed-failed.txt'), 'written before failure\n');
|
|
302
|
+
check(persistShell(failedMutationCall, { error: 'Exit code 7\nfixture failure' }, 'PostToolUseFailure').status === 0,
|
|
303
|
+
'failed shell command still reports workspace mutation through failure lifecycle');
|
|
304
|
+
check(read(file).budget.counters.iterations === ++observedIterations,
|
|
305
|
+
'failed shell command that changed workspace still consumes one iteration');
|
|
306
|
+
|
|
307
|
+
const beforeStageOnly = read(file);
|
|
308
|
+
const stageOnly = 'git add README.md observed-untracked.txt';
|
|
309
|
+
const stageOnlyCall = beginShell(stageOnly);
|
|
310
|
+
check(stageOnlyCall.result.status === 0, 'staging-only git command is observed instead of pre-counted');
|
|
311
|
+
git(['add', 'README.md', 'observed-untracked.txt']);
|
|
312
|
+
check(persistShell(stageOnlyCall).status === 0, 'staging-only probe resolves');
|
|
313
|
+
check(read(file).budget.counters.iterations === beforeStageOnly.budget.counters.iterations,
|
|
314
|
+
'git add does not double-count tracked or previously-untracked visible content');
|
|
315
|
+
|
|
316
|
+
const beforeCommitOnly = read(file);
|
|
317
|
+
const commitOnly = 'git commit -m observed-fixture';
|
|
318
|
+
const commitOnlyCall = beginShell(commitOnly);
|
|
319
|
+
check(commitOnlyCall.result.status === 0, 'pure commit is observed instead of pre-counted');
|
|
320
|
+
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'observed-fixture']);
|
|
321
|
+
check(persistShell(commitOnlyCall).status === 0, 'pure commit probe resolves');
|
|
322
|
+
check(read(file).budget.counters.iterations === beforeCommitOnly.budget.counters.iterations,
|
|
323
|
+
'pure commit of already-accounted content does not consume another code iteration');
|
|
324
|
+
|
|
325
|
+
const deleteTracked = 'node delete-tracked.js';
|
|
326
|
+
const deleteTrackedCall = beginShell(deleteTracked);
|
|
327
|
+
check(deleteTrackedCall.result.status === 0, 'unknown tracked-file deletion is admitted under observation');
|
|
328
|
+
fs.unlinkSync(path.join(repo, 'observed-untracked.txt'));
|
|
329
|
+
check(persistShell(deleteTrackedCall).status === 0, 'tracked-file deletion is observed');
|
|
330
|
+
check(read(file).budget.counters.iterations === ++observedIterations,
|
|
331
|
+
'tracked-file deletion consumes exactly one iteration');
|
|
332
|
+
|
|
333
|
+
const beforeStageDelete = read(file);
|
|
334
|
+
const stageDelete = 'git add -u observed-untracked.txt';
|
|
335
|
+
const stageDeleteCall = beginShell(stageDelete);
|
|
336
|
+
check(stageDeleteCall.result.status === 0, 'staging-only deletion is observed instead of pre-counted');
|
|
337
|
+
git(['add', '-u', 'observed-untracked.txt']);
|
|
338
|
+
check(persistShell(stageDeleteCall).status === 0, 'staging deletion probe resolves');
|
|
339
|
+
check(read(file).budget.counters.iterations === beforeStageDelete.budget.counters.iterations,
|
|
340
|
+
'git add -u does not double-count an already-observed deletion');
|
|
341
|
+
|
|
342
|
+
observedIterations = read(file).budget.counters.iterations;
|
|
343
|
+
for (let i = observedIterations; i < iterationLimit; i++) {
|
|
344
|
+
check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `confirmed mutation ${i + 1}/${iterationLimit} stays within budget`);
|
|
188
345
|
}
|
|
189
|
-
check(gate('
|
|
346
|
+
check(gate('Bash', { command: 'git status --short' }, repo).status === 0,
|
|
347
|
+
'proven read-only diagnosis remains allowed at the iteration limit');
|
|
348
|
+
check(gate('Bash', { command: 'git remote -v' }, repo).status === 2,
|
|
349
|
+
'untrusted shell command is blocked before execution once actual mutation budget is full');
|
|
350
|
+
check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2,
|
|
351
|
+
'blocked exhausted workflow still rejects direct mutation');
|
|
190
352
|
const iterativeExhausted = read(file);
|
|
191
353
|
check(iterativeExhausted.state === 'blocked' && iterativeExhausted.budget?.state === 'budget-exhausted' &&
|
|
192
354
|
iterativeExhausted.budget?.reasonCode === 'iteration-budget-exhausted', 'iterative exhaustion records blocked budget state');
|
|
@@ -200,11 +362,16 @@ try {
|
|
|
200
362
|
check(stop().status === 2, 'direct/iterative route cannot complete unverified mutation');
|
|
201
363
|
check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'iterative retry reports incomplete state');
|
|
202
364
|
check(control('start').status === 0, 'blocked iterative route can resume without a Fable stage map');
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
check(
|
|
365
|
+
// #153 + #159 ordering: when one shell command mutates and then verifies,
|
|
366
|
+
// observed mutation must land before state-persist records verification.
|
|
367
|
+
const mutateAndVerify = 'node mutate.js && npm test';
|
|
368
|
+
const mutateAndVerifyCall = beginShell(mutateAndVerify);
|
|
369
|
+
check(mutateAndVerifyCall.result.status === 0, 'combined mutation+verification command is admitted under observation');
|
|
370
|
+
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture verified mutation\n');
|
|
371
|
+
check(persistShell(mutateAndVerifyCall, { stdout: 'tests passed' }).status === 0,
|
|
372
|
+
'single state-persist handler records mutation before verification');
|
|
373
|
+
check(stop().status === 0 && read(file).state === 'satisfied',
|
|
374
|
+
'verification without a host-reported numeric exit status resolves after same-command mutation');
|
|
208
375
|
node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { exitCode: 0, stdout: 'passed' } });
|
|
209
376
|
check(stop().status === 0 && read(file).state === 'satisfied', 'observed iterative verification resolves completion');
|
|
210
377
|
console.log(`PASS: workflow lifecycle (${passed} assertions)`);
|
package/hooks/hooks.json
CHANGED
|
@@ -157,6 +157,19 @@
|
|
|
157
157
|
"id": "harness:stop:skill-telemetry"
|
|
158
158
|
}
|
|
159
159
|
],
|
|
160
|
+
"PermissionDenied": [
|
|
161
|
+
{
|
|
162
|
+
"matcher": "Bash|PowerShell",
|
|
163
|
+
"hooks": [
|
|
164
|
+
{
|
|
165
|
+
"type": "command",
|
|
166
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/workflow-mutation-denied.js\""
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
"description": "Release reserved mutation capacity when Claude auto mode denies a shell tool call",
|
|
170
|
+
"id": "harness:permission-denied:mutation-probe"
|
|
171
|
+
}
|
|
172
|
+
],
|
|
160
173
|
"PostToolUse": [
|
|
161
174
|
{
|
|
162
175
|
"matcher": "Bash|PowerShell|mcp__.*",
|
|
@@ -188,7 +201,7 @@
|
|
|
188
201
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-persist.js\""
|
|
189
202
|
}
|
|
190
203
|
],
|
|
191
|
-
"description": "
|
|
204
|
+
"description": "Persist tool outcome, observed workspace mutation, and verification milestones",
|
|
192
205
|
"id": "harness:post:state-persist"
|
|
193
206
|
},
|
|
194
207
|
{
|
|
@@ -259,6 +272,17 @@
|
|
|
259
272
|
"description": "Track failed tool error signatures for Rule of 3",
|
|
260
273
|
"id": "harness:post-failure:rule-of-3-tracker"
|
|
261
274
|
},
|
|
275
|
+
{
|
|
276
|
+
"matcher": "Bash|PowerShell",
|
|
277
|
+
"hooks": [
|
|
278
|
+
{
|
|
279
|
+
"type": "command",
|
|
280
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-persist.js\""
|
|
281
|
+
}
|
|
282
|
+
],
|
|
283
|
+
"description": "Persist failed shell outcome and any workspace mutation that occurred before failure",
|
|
284
|
+
"id": "harness:post-failure:state-persist"
|
|
285
|
+
},
|
|
262
286
|
{
|
|
263
287
|
"matcher": "Skill",
|
|
264
288
|
"hooks": [
|
|
@@ -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) {
|
|
@@ -144,9 +220,9 @@ function splitShellSegments(text) {
|
|
|
144
220
|
|
|
145
221
|
function classifyShell(command) {
|
|
146
222
|
const text = String(command || '').trim();
|
|
147
|
-
if (!text) return '
|
|
223
|
+
if (!text) return 'untrusted';
|
|
148
224
|
const segments = splitShellSegments(text);
|
|
149
|
-
if (!segments) return '
|
|
225
|
+
if (!segments) return 'untrusted';
|
|
150
226
|
if (segments.length === 1) {
|
|
151
227
|
if (/^git\s+worktree\s+add\s+/i.test(segments[0]) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(segments[0])) return 'worktree-setup';
|
|
152
228
|
return isReadOnlySegment(segments[0]) ? 'read-only' : 'mutation-or-unknown';
|
|
@@ -198,4 +274,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
198
274
|
}
|
|
199
275
|
}
|
|
200
276
|
|
|
201
|
-
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 };
|
|
@@ -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) {
|
|
@@ -144,9 +220,9 @@ function splitShellSegments(text) {
|
|
|
144
220
|
|
|
145
221
|
function classifyShell(command) {
|
|
146
222
|
const text = String(command || '').trim();
|
|
147
|
-
if (!text) return '
|
|
223
|
+
if (!text) return 'untrusted';
|
|
148
224
|
const segments = splitShellSegments(text);
|
|
149
|
-
if (!segments) return '
|
|
225
|
+
if (!segments) return 'untrusted';
|
|
150
226
|
if (segments.length === 1) {
|
|
151
227
|
if (/^git\s+worktree\s+add\s+/i.test(segments[0]) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(segments[0])) return 'worktree-setup';
|
|
152
228
|
return isReadOnlySegment(segments[0]) ? 'read-only' : 'mutation-or-unknown';
|
|
@@ -198,4 +274,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
198
274
|
}
|
|
199
275
|
}
|
|
200
276
|
|
|
201
|
-
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 };
|
|
@@ -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);
|
|
@@ -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.11.
|
|
4
|
+
"version": "0.11.6",
|
|
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",
|