harness-everything 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +173 -2
- package/docs/enforcement-locks.md +114 -0
- package/hooks/scripts/lib/workflow-isolation.js +16 -3
- package/hooks/scripts/lib/workflow-runtime.js +110 -7
- package/hooks/scripts/state-persist.js +6 -9
- package/hooks/scripts/workflow-gate.js +16 -2
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +16 -3
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +110 -7
- package/plugins/harness-everything/hooks/scripts/state-persist.js +6 -9
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +16 -2
- 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.12.
|
|
13
|
+
"version": "0.12.2",
|
|
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.12.
|
|
4
|
+
"version": "0.12.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.12.2](https://github.com/dyphn1/Harness-everything/compare/v0.12.1...v0.12.2) (2026-09-19)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **workflow:** admit observation-only verification at limit ([08b859e](https://github.com/dyphn1/Harness-everything/commit/08b859ef27c04a07d9a0a6faeeed52f3da986378))
|
|
10
|
+
* **workflow:** admit observation-only verification at limit ([8924a3c](https://github.com/dyphn1/Harness-everything/commit/8924a3cc80455592accbe81e3f2df0f4479a2d8b))
|
|
11
|
+
* **workflow:** allow verification probe at iteration limit ([7ce511e](https://github.com/dyphn1/Harness-everything/commit/7ce511e29ca368371d996f4cbeff02298d97434c))
|
|
12
|
+
* **workflow:** allow verification probe at iteration limit ([07e6ed0](https://github.com/dyphn1/Harness-everything/commit/07e6ed0b369e38f274ac099af590b12373b1ad1c))
|
|
13
|
+
|
|
14
|
+
## [0.12.1](https://github.com/dyphn1/Harness-everything/compare/v0.12.0...v0.12.1) (2026-09-19)
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **workflow:** bind probe lease to tool timeout ([bb8c598](https://github.com/dyphn1/Harness-everything/commit/bb8c598792530f7a06f8514c51dc9905fc78935c))
|
|
19
|
+
* **workflow:** bind probe lease to tool timeout ([025d6eb](https://github.com/dyphn1/Harness-everything/commit/025d6eb899be92598836ddc0c25c2eced8d4d190))
|
|
20
|
+
* **workflow:** discard unchanged stale probe orphans ([aa8d238](https://github.com/dyphn1/Harness-everything/commit/aa8d238199285b7338edf6b87495cd7454a440ac))
|
|
21
|
+
* **workflow:** discard unchanged stale probe orphans ([49fa840](https://github.com/dyphn1/Harness-everything/commit/49fa8409eb337a8acb5f6149323298b0b82027e8))
|
|
22
|
+
* **workflow:** keep active mutation probes leased ([c62831f](https://github.com/dyphn1/Harness-everything/commit/c62831f64d6b5e3991b4cde34a0bf263c7cab2e7))
|
|
23
|
+
* **workflow:** keep active mutation probes leased ([23f8b3d](https://github.com/dyphn1/Harness-everything/commit/23f8b3d646747f7e538061490a1a9bbf05fb8c39))
|
|
24
|
+
* **workflow:** key shell probes by stable tool-use identity ([4d24ca6](https://github.com/dyphn1/Harness-everything/commit/4d24ca6f538f1c1ca0d3165c1b8adce930a8d9d4))
|
|
25
|
+
* **workflow:** key shell probes by stable tool-use identity ([56c24b4](https://github.com/dyphn1/Harness-everything/commit/56c24b49748d9bd74cc3fe634cce291a691baa15))
|
|
26
|
+
* **workflow:** persist probe observation cwd ([51c5894](https://github.com/dyphn1/Harness-everything/commit/51c58945dbed392424e4690f6be78ad68db8ecbf))
|
|
27
|
+
* **workflow:** persist probe observation cwd ([9e361f1](https://github.com/dyphn1/Harness-everything/commit/9e361f1233f08f2f74c376c38e5d34dab017cbae))
|
|
28
|
+
* **workflow:** reclaim stale mutation-probe reservations ([fe613ec](https://github.com/dyphn1/Harness-everything/commit/fe613ec7cdea09e8b535e60f8a1a6468f649006d))
|
|
29
|
+
* **workflow:** reclaim stale mutation-probe reservations ([62f44d0](https://github.com/dyphn1/Harness-everything/commit/62f44d0f455ea3baf14b864aa33b1c868c6ef945))
|
|
30
|
+
* **workflow:** settle probes from their pre-tool workspace ([e16f7da](https://github.com/dyphn1/Harness-everything/commit/e16f7da4a2290afb1603e9c82b775b4fc6872acb))
|
|
31
|
+
* **workflow:** settle probes from their pre-tool workspace ([a7cce6c](https://github.com/dyphn1/Harness-everything/commit/a7cce6c5d7369bd40565978534c09687c8101408))
|
|
32
|
+
* **workflow:** use conservative default probe timeout ([e4d4e0f](https://github.com/dyphn1/Harness-everything/commit/e4d4e0f0f31d9feed068eb98abcba961c4ee078a))
|
|
33
|
+
* **workflow:** use conservative default probe timeout ([1393450](https://github.com/dyphn1/Harness-everything/commit/13934502a715b1cfa3b8caa76a300b38f0059afd))
|
|
34
|
+
|
|
5
35
|
## [0.12.0](https://github.com/dyphn1/Harness-everything/compare/v0.11.7...v0.12.0) (2026-09-19)
|
|
6
36
|
|
|
7
37
|
### Features
|
package/README.md
CHANGED
|
@@ -255,6 +255,7 @@ For a deep dive into individual modules and the underlying philosophy, explore o
|
|
|
255
255
|
|
|
256
256
|
* [Harness Philosophy](docs/philosophy.md): The core behavior-first, intervention-only design.
|
|
257
257
|
* [Harness Architecture](docs/architecture.md): Lifecycle hooks, security model, and data locality.
|
|
258
|
+
* [Enforcement and Lock Diagnostic Flows](docs/enforcement-locks.md): Identify which gate blocked progress, why it fired, and how to recover.
|
|
258
259
|
* [Platform Capability Matrix](docs/platform-capabilities.md): Canonical current enforcement/install/evidence boundary per surface.
|
|
259
260
|
* [Repository Runtime & Workflow Contract](docs/repository-contract.md): Generated Node/action/workflow/gate state used by consistency CI.
|
|
260
261
|
* [OpenAI / ChatGPT Plugin Packaging](docs/openai-plugin.md): Local Codex/OpenAI plugin packaging plus public Skills-only submission boundary.
|
|
@@ -45,9 +45,15 @@ const persistDirect = (tool, input, cwd = repo, response = {}) => node('hooks/sc
|
|
|
45
45
|
tool_response: response,
|
|
46
46
|
});
|
|
47
47
|
let toolUseSeq = 0;
|
|
48
|
-
function beginShell(command, cwd = repo) {
|
|
48
|
+
function beginShell(command, cwd = repo, extraToolInput = {}) {
|
|
49
49
|
const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
|
|
50
|
-
const input = {
|
|
50
|
+
const input = {
|
|
51
|
+
...payload,
|
|
52
|
+
cwd,
|
|
53
|
+
tool_name: 'Bash',
|
|
54
|
+
tool_use_id: toolUseId,
|
|
55
|
+
tool_input: { command, ...extraToolInput },
|
|
56
|
+
};
|
|
51
57
|
return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
|
|
52
58
|
}
|
|
53
59
|
function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
|
|
@@ -84,6 +90,13 @@ const probeFiles = () => {
|
|
|
84
90
|
try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
|
|
85
91
|
catch (_) { return []; }
|
|
86
92
|
};
|
|
93
|
+
function ageProbeFiles(ms = 60000) {
|
|
94
|
+
for (const name of probeFiles()) {
|
|
95
|
+
const target = path.join(probeDir, name);
|
|
96
|
+
const probe = read(target);
|
|
97
|
+
write(target, { ...probe, observedAt: Date.now() - ms, reclaimAfterAt: Date.now() - 1 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
87
100
|
const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
|
|
88
101
|
const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
|
|
89
102
|
function fixtureSession(fixtureSessionId) {
|
|
@@ -190,6 +203,58 @@ try {
|
|
|
190
203
|
git(['init']); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); git(['add', '.']);
|
|
191
204
|
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
|
|
192
205
|
git(['worktree', 'add', linked, '-b', 'isolated']);
|
|
206
|
+
|
|
207
|
+
// #164: the mutation budget is an edit budget, not a reason to make the
|
|
208
|
+
// final edit unverifiable. At the exact limit a verification-shaped shell
|
|
209
|
+
// gets observation-only admission. No-effect verification may complete the
|
|
210
|
+
// workflow; a verification command that mutates still exhausts after Post.
|
|
211
|
+
const finalVerify = fixtureSession('workflow-final-iteration-verify');
|
|
212
|
+
check(finalVerify.route('Fix this checkout bug with a regression test').status === 0 &&
|
|
213
|
+
finalVerify.control('start').status === 0,
|
|
214
|
+
'#164 final-iteration verification fixture starts Tier 2');
|
|
215
|
+
const finalVerifyLimit = read(finalVerify.file).workflowPlan.limits.maxIterations;
|
|
216
|
+
for (let i = 0; i < finalVerifyLimit; i++) {
|
|
217
|
+
check(finalVerify.gate('Write', { file_path: path.join(repo, 'final-verify-' + i + '.txt') }).status === 0,
|
|
218
|
+
'#164 confirmed mutation fills iteration budget ' + (i + 1) + '/' + finalVerifyLimit);
|
|
219
|
+
}
|
|
220
|
+
check(read(finalVerify.file).budget.counters.iterations === finalVerifyLimit,
|
|
221
|
+
'#164 fixture reaches the exact mutation limit while still running');
|
|
222
|
+
const finalVerifyId = 'toolu_final_iteration_verify';
|
|
223
|
+
check(finalVerify.gate('Bash', { command: 'node run-test.js' }, { tool_use_id: finalVerifyId }).status === 0,
|
|
224
|
+
'#164 no-effect verification shell is admitted at the exact mutation limit');
|
|
225
|
+
check(finalVerify.persist('Bash', { command: 'node run-test.js' }, { stdout: 'tests passed' },
|
|
226
|
+
{ tool_use_id: finalVerifyId }).status === 0,
|
|
227
|
+
'#164 no-effect verification settles without exhausting the budget');
|
|
228
|
+
check(read(finalVerify.file).budget.counters.iterations === finalVerifyLimit &&
|
|
229
|
+
read(finalVerify.file).state === 'running',
|
|
230
|
+
'#164 no-effect verification preserves the full mutation count and running state');
|
|
231
|
+
check(finalVerify.stop().status === 0 && read(finalVerify.file).state === 'satisfied',
|
|
232
|
+
'#164 verification after the final allowed edit can satisfy Stop');
|
|
233
|
+
|
|
234
|
+
const finalMutator = fixtureSession('workflow-final-iteration-mutator');
|
|
235
|
+
check(finalMutator.route('Fix this checkout bug with a regression test').status === 0 &&
|
|
236
|
+
finalMutator.control('start').status === 0,
|
|
237
|
+
'#164 at-limit mutator fixture starts Tier 2');
|
|
238
|
+
const finalMutatorLimit = read(finalMutator.file).workflowPlan.limits.maxIterations;
|
|
239
|
+
for (let i = 0; i < finalMutatorLimit; i++) {
|
|
240
|
+
check(finalMutator.gate('Write', { file_path: path.join(repo, 'final-mutator-' + i + '.txt') }).status === 0,
|
|
241
|
+
'#164 mutator fixture fills iteration budget ' + (i + 1) + '/' + finalMutatorLimit);
|
|
242
|
+
}
|
|
243
|
+
const finalMutatorId = 'toolu_final_iteration_mutator';
|
|
244
|
+
check(finalMutator.gate('Bash', { command: 'node run-test-and-mutate.js' }, { tool_use_id: finalMutatorId }).status === 0,
|
|
245
|
+
'#164 verification-shaped command receives observation-only admission at limit');
|
|
246
|
+
const finalOverflowTarget = path.join(repo, 'final-iteration-overflow.txt');
|
|
247
|
+
fs.writeFileSync(finalOverflowTarget, 'mutation beyond limit\n');
|
|
248
|
+
check(finalMutator.persist('Bash', { command: 'node run-test-and-mutate.js' }, { stdout: 'changed' },
|
|
249
|
+
{ tool_use_id: finalMutatorId }).status === 2,
|
|
250
|
+
'#164 at-limit verification-shaped command that mutates is rejected on observation');
|
|
251
|
+
const finalMutatorState = read(finalMutator.file);
|
|
252
|
+
check(finalMutatorState.state === 'blocked' &&
|
|
253
|
+
finalMutatorState.budget?.state === 'budget-exhausted' &&
|
|
254
|
+
finalMutatorState.budget?.reasonCode === 'iteration-budget-exhausted',
|
|
255
|
+
'#164 observed mutation beyond the limit leaves an auditable blocked workflow');
|
|
256
|
+
fs.unlinkSync(finalOverflowTarget);
|
|
257
|
+
|
|
193
258
|
const taskNotification = '<task-notification><summary>Background command "Run full test suite" failed with exit code 1</summary></task-notification>';
|
|
194
259
|
const unboundNotification = node('harness-everything/scripts/kernel-router.js', { cwd: repo, prompt: taskNotification });
|
|
195
260
|
check(unboundNotification.status === 0 &&
|
|
@@ -524,6 +589,112 @@ try {
|
|
|
524
589
|
'no-effect untrusted shell command does not advance lastMutationAt');
|
|
525
590
|
}
|
|
526
591
|
|
|
592
|
+
// #161: Post payload cwd may differ from Pre payload cwd after a shell
|
|
593
|
+
// command changes directories. Stable tool_use_id identity plus the stored
|
|
594
|
+
// Pre-time observation cwd must settle the original probe.
|
|
595
|
+
const driftCwd = path.join(repo, 'hooks');
|
|
596
|
+
fs.mkdirSync(driftCwd, { recursive: true });
|
|
597
|
+
const driftNoEffectBefore = read(file);
|
|
598
|
+
const driftNoEffect = beginShell('cd hooks && node -e "process.exit(0)"', repo);
|
|
599
|
+
check(driftNoEffect.result.status === 0 && probeFiles().length === 1,
|
|
600
|
+
'#161 cwd-drift no-effect call registers one probe');
|
|
601
|
+
const driftNoEffectPost = node('hooks/scripts/state-persist.js', {
|
|
602
|
+
...payload,
|
|
603
|
+
cwd: driftCwd,
|
|
604
|
+
hook_event_name: 'PostToolUse',
|
|
605
|
+
tool_name: 'Bash',
|
|
606
|
+
tool_use_id: driftNoEffect.toolUseId,
|
|
607
|
+
tool_input: { command: driftNoEffect.command },
|
|
608
|
+
tool_response: { stdout: 'ok' },
|
|
609
|
+
});
|
|
610
|
+
check(driftNoEffectPost.status === 0 && probeFiles().length === 0,
|
|
611
|
+
'#161 Post with drifted cwd settles the Pre-time probe');
|
|
612
|
+
check((read(file).budget?.counters?.iterations || 0) === (driftNoEffectBefore.budget?.counters?.iterations || 0),
|
|
613
|
+
'#161 cwd-drift no-effect shell consumes zero iterations');
|
|
614
|
+
|
|
615
|
+
const driftMutationBefore = read(file);
|
|
616
|
+
const driftMutation = beginShell('cd hooks && node mutate-from-subdir.js', repo);
|
|
617
|
+
check(driftMutation.result.status === 0, '#161 cwd-drift mutator is admitted under observation');
|
|
618
|
+
fs.writeFileSync(path.join(repo, 'drift-mutated.txt'), 'changed under cwd drift\n');
|
|
619
|
+
const driftMutationPost = node('hooks/scripts/state-persist.js', {
|
|
620
|
+
...payload,
|
|
621
|
+
cwd: driftCwd,
|
|
622
|
+
hook_event_name: 'PostToolUse',
|
|
623
|
+
tool_name: 'Bash',
|
|
624
|
+
tool_use_id: driftMutation.toolUseId,
|
|
625
|
+
tool_input: { command: driftMutation.command },
|
|
626
|
+
tool_response: { stdout: 'changed' },
|
|
627
|
+
});
|
|
628
|
+
const driftMutationAfter = read(file);
|
|
629
|
+
check(driftMutationPost.status === 0 && probeFiles().length === 0 &&
|
|
630
|
+
(driftMutationAfter.budget?.counters?.iterations || 0) === (driftMutationBefore.budget?.counters?.iterations || 0) + 1 &&
|
|
631
|
+
driftMutationAfter.lastMutationAt >= (driftMutationBefore.lastMutationAt || 0),
|
|
632
|
+
'#161 cwd-drift real mutation is observed exactly once');
|
|
633
|
+
|
|
634
|
+
// A live command may legitimately run longer than the old 1s grace period.
|
|
635
|
+
// Its probe must remain reserved until the declared tool timeout expires.
|
|
636
|
+
const longRunningStateBefore = read(file);
|
|
637
|
+
const longRunningBefore = longRunningStateBefore.budget.counters.iterations;
|
|
638
|
+
const longRunningA = beginShell('node long-running-mutator.js', repo, { timeout: 60000 });
|
|
639
|
+
check(longRunningA.result.status === 0 && probeFiles().length === 1,
|
|
640
|
+
'#161 long-running call registers one timeout-bound probe');
|
|
641
|
+
{
|
|
642
|
+
const target = path.join(probeDir, probeFiles()[0]);
|
|
643
|
+
const probe = read(target);
|
|
644
|
+
write(target, { ...probe, observedAt: Date.now() - 2000 });
|
|
645
|
+
}
|
|
646
|
+
const concurrentB = beginShell('node concurrent-observer.js', repo, { timeout: 60000 });
|
|
647
|
+
check(concurrentB.result.status === 0 && probeFiles().length === 2,
|
|
648
|
+
'#161 a second Pre does not reclaim an in-flight probe merely because it is older than 1s');
|
|
649
|
+
fs.writeFileSync(path.join(repo, 'long-running-mutated.txt'), 'changed by long-running A\n');
|
|
650
|
+
check(persistShell(longRunningA).status === 0 &&
|
|
651
|
+
read(file).budget.counters.iterations === longRunningBefore + 1,
|
|
652
|
+
'#161 long-running A still settles and records its later mutation');
|
|
653
|
+
check(denyShell(concurrentB).status === 0 && probeFiles().length === 0,
|
|
654
|
+
'#161 concurrent non-mutating probe can be denied independently after A settles');
|
|
655
|
+
fs.rmSync(path.join(repo, 'long-running-mutated.txt'), { force: true });
|
|
656
|
+
write(file, longRunningStateBefore);
|
|
657
|
+
check(read(file).budget.counters.iterations === longRunningBefore,
|
|
658
|
+
'#161 long-running regression restores the shared fixture budget before later boundary tests');
|
|
659
|
+
|
|
660
|
+
// Simulate default-mode denial / sibling PreToolUse blocking: Pre fires but
|
|
661
|
+
// neither PostToolUse nor PermissionDenied follows. Force each orphan past
|
|
662
|
+
// its timeout-bound reclaim deadline so the next real Pre can reclaim it
|
|
663
|
+
// deterministically without sleeping in CI.
|
|
664
|
+
const orphanStartIterations = read(file).budget.counters.iterations;
|
|
665
|
+
for (let i = 0; i < iterationLimit; i++) {
|
|
666
|
+
const orphan = beginShell('node rejected-before-exec-' + i + '.js', repo);
|
|
667
|
+
check(orphan.result.status === 0, '#161 Pre-only orphan ' + (i + 1) + ' is initially admitted');
|
|
668
|
+
ageProbeFiles();
|
|
669
|
+
}
|
|
670
|
+
check(workflowRuntime.mutationProbeReservations(workflowRuntime.loadWorkflow(payload)) === 0,
|
|
671
|
+
'#161 stale Pre-only probes no longer reserve iteration capacity');
|
|
672
|
+
const harmlessAfterOrphans = beginShell('node harmless-after-orphans.js', repo);
|
|
673
|
+
check(harmlessAfterOrphans.result.status === 0 && probeFiles().length === 1,
|
|
674
|
+
'#161 next harmless untrusted shell is admitted after maxIterations Pre-only orphans');
|
|
675
|
+
check(persistShell(harmlessAfterOrphans).status === 0 && probeFiles().length === 0,
|
|
676
|
+
'#161 orphan reclamation leaves only the live probe and it settles normally');
|
|
677
|
+
check(read(file).budget.counters.iterations === orphanStartIterations,
|
|
678
|
+
'#161 rejected/no-effect orphan sequence consumes zero iterations');
|
|
679
|
+
|
|
680
|
+
// If an orphan really did mutate the workspace, reclaim must account for it
|
|
681
|
+
// once rather than silently dropping evidence.
|
|
682
|
+
const staleMutation = beginShell('node orphaned-mutator.js', repo);
|
|
683
|
+
check(staleMutation.result.status === 0, '#161 stale-mutator probe is registered');
|
|
684
|
+
ageProbeFiles();
|
|
685
|
+
const staleMutationBefore = read(file);
|
|
686
|
+
fs.writeFileSync(path.join(repo, 'stale-probe-mutated.txt'), 'changed before missing Post\n');
|
|
687
|
+
const reclaimTrigger = beginShell('node reclaim-trigger.js', repo);
|
|
688
|
+
const reclaimed = read(file);
|
|
689
|
+
check(reclaimTrigger.result.status === 0 &&
|
|
690
|
+
reclaimed.budget.counters.iterations === staleMutationBefore.budget.counters.iterations + 1 &&
|
|
691
|
+
reclaimed.lastMutationAt >= (staleMutationBefore.lastMutationAt || 0),
|
|
692
|
+
'#161 stale probe with workspace change is reclaimed as exactly one mutation');
|
|
693
|
+
check(persistShell(reclaimTrigger).status === 0 &&
|
|
694
|
+
read(file).budget.counters.iterations === reclaimed.budget.counters.iterations &&
|
|
695
|
+
probeFiles().length === 0,
|
|
696
|
+
'#161 reclaimed stale mutation is not double-counted by the trigger Post');
|
|
697
|
+
|
|
527
698
|
const deniedCall = beginShell('node denied-by-auto-mode.js');
|
|
528
699
|
check(deniedCall.result.status === 0 && probeFiles().length === 1,
|
|
529
700
|
'auto-mode candidate reserves mutation capacity before permission decision');
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Enforcement and Lock Diagnostic Flows
|
|
2
|
+
|
|
3
|
+
Harness has several mechanisms that can deny a tool call or delay completion. Only one of them is a repeated-failure circuit breaker. Use the emitted mechanism name and the flows below before describing a denial as a lock problem.
|
|
4
|
+
|
|
5
|
+
This guide describes the repository's current hook wiring. Mechanical enforcement depends on the host and installation surface; see [Platform Capability Matrix](platform-capabilities.md) before generalizing a behavior to every host.
|
|
6
|
+
|
|
7
|
+
## Lifecycle map
|
|
8
|
+
|
|
9
|
+
On the Claude hook surface, a request enters the router before tool use. Pre-tool gates decide whether a tool call may start, post-tool hooks record outcomes, and stop gates decide whether the turn may finish.
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart TD
|
|
13
|
+
A["User prompt"] --> B["Kernel router<br/>select workflow contract"]
|
|
14
|
+
B --> C["Pre-tool gates"]
|
|
15
|
+
C --> D{"Tool allowed?"}
|
|
16
|
+
D -- "No" --> E["Emit gate-specific reason<br/>and recovery"]
|
|
17
|
+
D -- "Yes" --> F["Execute tool"]
|
|
18
|
+
F --> G["Post-tool outcome tracking"]
|
|
19
|
+
G --> H{"Turn stopping?"}
|
|
20
|
+
H -- "No" --> C
|
|
21
|
+
H -- "Yes" --> I["Completion gates"]
|
|
22
|
+
I --> J{"Obligations resolved?"}
|
|
23
|
+
J -- "Yes" --> K["Evidence-backed completion"]
|
|
24
|
+
J -- "No" --> E
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The important separation is:
|
|
28
|
+
|
|
29
|
+
- **Pre-tool denial:** the requested operation did not run.
|
|
30
|
+
- **Post-tool tracking:** the operation ran and its outcome updated state.
|
|
31
|
+
- **Stop denial:** the turn cannot claim completion yet; it does not necessarily prevent the next tool call.
|
|
32
|
+
- **Approval request:** the exact destructive or external action waits for authorization; it is not a retry lock.
|
|
33
|
+
|
|
34
|
+
## Which mechanism stopped progress?
|
|
35
|
+
|
|
36
|
+
Start with the prefix in the error or hook message. Do not infer the mechanism only from the fact that a command did not run.
|
|
37
|
+
|
|
38
|
+
```mermaid
|
|
39
|
+
flowchart TD
|
|
40
|
+
A["Operation or completion blocked"] --> B{"Message prefix or condition"}
|
|
41
|
+
B -- "RULE OF 3" --> C["Repeated-failure circuit breaker"]
|
|
42
|
+
B -- "Workflow gate" --> D["Workflow entry, state,<br/>run, or worktree precondition"]
|
|
43
|
+
B -- "Action gate or permission" --> E["Exact side effect needs approval"]
|
|
44
|
+
B -- "Workflow Completion Gate" --> F["Selected workflow obligations unresolved"]
|
|
45
|
+
B -- "Stop Gate" --> G["Edits lack later successful verification"]
|
|
46
|
+
B -- "Boundary, depth, context,<br/>or scope guard" --> H["Guard-specific policy denial"]
|
|
47
|
+
C --> I["Follow Rule of 3 recovery flow"]
|
|
48
|
+
D --> J["Satisfy named workflow precondition"]
|
|
49
|
+
E --> K["Approve, reject, or change exact payload"]
|
|
50
|
+
F --> L["Resolve stages or report blocked state"]
|
|
51
|
+
G --> M["Run a relevant check or state why not applicable"]
|
|
52
|
+
H --> N["Follow the emitted guard reason"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If a message has no Harness prefix, check the host permission system and the tool's own error before changing Harness state.
|
|
56
|
+
|
|
57
|
+
## Mechanism reference
|
|
58
|
+
|
|
59
|
+
| Mechanism | Trigger | What it blocks | State and persistence | Recovery |
|
|
60
|
+
|---|---|---|---|---|
|
|
61
|
+
| Rule of 3 | The same normalized shell failure signature reaches its category threshold. Defaults to 3; timeout and permission use 2; environment and dependency use 4. | Mutating tools. Read-only investigation remains available, and the required zoom-out report is the only permitted write. | Session-scoped `rule-of-3-state.json`. A second trip of the same signature after one accepted reflection becomes a human-decision hard lock. | First trip: produce a fresh valid `zoom-out-report.md`. Repeat trip: human runs `npm run harness:reset`, starts a new session, or uses `/clear`. |
|
|
62
|
+
| Workflow gate | Active workflow state or entry contract is incompatible with the requested mutation; examples include blocked/failed/satisfied state, missing correlated Fable run, or missing linked worktree for major work. | The requested mutation. Read-only commands and trusted workflow-controller commands have narrow exceptions. | Session workflow state. Major-workflow isolation is a hard precondition, not an automatic fallback. | Use the exact message: enter a linked worktree, start/replan through the controller, resolve blocked state, or submit a new task after satisfaction. |
|
|
63
|
+
| Action gate | A shell rule or structured destructive/external action matches the policy. | Only the exact action payload; its hash is used for audit correlation. | Approval/audit state records pending, executed, failed, or rejected disposition. | Approve or reject through the host flow, or change the action. Approval of one payload does not authorize a different payload. |
|
|
64
|
+
| Workflow completion gate | A selected workflow is stopping with unresolved stage, correlation, or verification obligations. | Completion. On a repeated stop-hook attempt it records a blocked state instead of looping forever. | `workflow-run.json` plus correlated stage/handoff state. | Resolve the named obligation. If genuinely blocked, report the blocker rather than claim completion. |
|
|
65
|
+
| Ordinary stop gate | A dirty tree contains edits with no later successful verification-like command. | Completion once per edit batch. It is intentionally a bounce, not a persistent cage. | `stop-gate-state.json` remembers the edit timestamp already bounced. | Run a targeted test/build/lint check, or explicitly state why verification is not applicable and stop again. |
|
|
66
|
+
| Boundary/depth/context/scope guards | A guard-specific read, write, context-pressure, or subagent-scope condition matches. | The matching tool or workflow step. | Mechanism-specific; these are not Rule of 3 counters. | Follow the emitted reason and the corresponding architecture documentation. |
|
|
67
|
+
|
|
68
|
+
## Rule of 3 state transitions
|
|
69
|
+
|
|
70
|
+
The tracker hashes a normalized failure together with category, file path, and command context. A different signature starts a new counter. A confirmed successful action clears recovery state.
|
|
71
|
+
|
|
72
|
+
```mermaid
|
|
73
|
+
stateDiagram-v2
|
|
74
|
+
[*] --> Counting
|
|
75
|
+
Counting --> Counting: same signature below threshold
|
|
76
|
+
Counting --> Reflecting: threshold reached
|
|
77
|
+
Reflecting --> Counting: valid fresh report with RESUME
|
|
78
|
+
Reflecting --> Escalating: valid fresh report with ESCALATE
|
|
79
|
+
Counting --> HardLocked: same signature trips after reflection
|
|
80
|
+
HardLocked --> [*]: human reset or new session
|
|
81
|
+
Counting --> [*]: confirmed success
|
|
82
|
+
Escalating --> HardLocked: mutation retried and signature trips again
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A valid reflection report must be newer than the failure that tripped the breaker, contain `Goal`, `Failed Attempts`, `Verified Facts`, `Diagnosis`, and `Decision`, and end in a `RESUME` or `ESCALATE` decision. A stale report cannot release a later trip.
|
|
86
|
+
|
|
87
|
+
## Pre-tool ordering and overlapping denials
|
|
88
|
+
|
|
89
|
+
The configured Claude pre-tool order is workflow gate, action gate, Rule of 3, then the narrower boundary/depth/context/scope guards for their matched tools. This means the first emitted denial identifies the condition reached on that call; it does not prove that every later gate would pass.
|
|
90
|
+
|
|
91
|
+
For example:
|
|
92
|
+
|
|
93
|
+
1. A force push may first fail the workflow gate if the selected workflow has not been entered.
|
|
94
|
+
2. After workflow entry is fixed, the action gate may still require approval for the same force push.
|
|
95
|
+
3. Rule of 3 is relevant only when repeated tracked failures reached the signature threshold; it should not be reset to bypass workflow or approval requirements.
|
|
96
|
+
|
|
97
|
+
Treat each denial independently and re-evaluate after satisfying its stated condition.
|
|
98
|
+
|
|
99
|
+
## Host and evidence boundary
|
|
100
|
+
|
|
101
|
+
The hook sequence above is the canonical Claude package wiring. The local OpenAI/Codex plugin packages related lifecycle adapters, while general installer paths and the public Skills-only artifact may be advisory rather than mechanically enforced. OpenCode has a separate plugin implementation and a narrower retained live-host evidence boundary.
|
|
102
|
+
|
|
103
|
+
Repository mechanism tests show that packaged code follows its tested contract. They do not by themselves prove that a particular live host loaded the package, fired the hook, or improved agent behavior. Record live-host evidence before making that stronger claim.
|
|
104
|
+
|
|
105
|
+
## Source map
|
|
106
|
+
|
|
107
|
+
Use these implementation files when this guide and runtime appear to disagree:
|
|
108
|
+
|
|
109
|
+
- `hooks/hooks.json` — lifecycle wiring and order.
|
|
110
|
+
- `hooks/scripts/rule-of-3.js` and `rule-of-3-tracker.js` — counter, thresholds, reflection, and hard-lock behavior.
|
|
111
|
+
- `hooks/scripts/workflow-gate.js` and `workflow-stop-gate.js` — mutation and completion workflow contracts.
|
|
112
|
+
- `hooks/scripts/action-gate.js` and `action-gate-rules.json` — approval policy and audit behavior.
|
|
113
|
+
- `hooks/scripts/stop-gate.js` — ordinary one-bounce verification behavior.
|
|
114
|
+
- [Harness Architecture](architecture.md) and [Workflow Runtime](workflow-runtime.md) — broader runtime contracts and limitations.
|
|
@@ -76,10 +76,18 @@ function workspaceFingerprint(cwd) {
|
|
|
76
76
|
function shellProbeKey(payload, cwd) {
|
|
77
77
|
const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
|
|
78
78
|
const tool = String(payload?.tool_name || payload?.tool || '');
|
|
79
|
+
if (toolUseId) {
|
|
80
|
+
// Host tool-use identity is stable across Pre/Post payload cwd drift.
|
|
81
|
+
return crypto.createHash('sha256')
|
|
82
|
+
.update('harness-shell-probe-v3\0')
|
|
83
|
+
.update(toolUseId).update('\0')
|
|
84
|
+
.update(tool)
|
|
85
|
+
.digest('hex');
|
|
86
|
+
}
|
|
87
|
+
// Compatibility fallback for hosts that do not expose tool_use_id.
|
|
79
88
|
const command = commandOf(payload);
|
|
80
89
|
return crypto.createHash('sha256')
|
|
81
|
-
.update('harness-shell-probe-v2\0')
|
|
82
|
-
.update(toolUseId || 'no-tool-use-id').update('\0')
|
|
90
|
+
.update('harness-shell-probe-v2-fallback\0')
|
|
83
91
|
.update(tool).update('\0')
|
|
84
92
|
.update(key(cwd)).update('\0')
|
|
85
93
|
.update(command)
|
|
@@ -127,6 +135,11 @@ function linkedWorktree(cwd, boundRoot) {
|
|
|
127
135
|
// Unsupported shell syntax still fails closed.
|
|
128
136
|
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;
|
|
129
137
|
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;
|
|
138
|
+
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
139
|
+
|
|
140
|
+
function isVerificationShell(command) {
|
|
141
|
+
return VERIFY_COMMAND_RE.test(String(command || ''));
|
|
142
|
+
}
|
|
130
143
|
|
|
131
144
|
function isReadOnlySegment(segment) {
|
|
132
145
|
return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
|
|
@@ -303,4 +316,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
303
316
|
}
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
319
|
+
module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getWorkspaceRoot, getSessionDir, getSessionId, getStateRoot } = require('./harness-state');
|
|
6
6
|
const { atomicWriteJson, readJson } = require('./fable-contracts');
|
|
7
|
+
const { workspaceFingerprint } = require('./workflow-isolation');
|
|
7
8
|
|
|
8
9
|
const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
|
|
9
10
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -45,6 +46,25 @@ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow)
|
|
|
45
46
|
|
|
46
47
|
const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
|
|
47
48
|
const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
49
|
+
const MUTATION_PROBE_DEFAULT_TIMEOUT_MS = 120000;
|
|
50
|
+
const MUTATION_PROBE_RECLAIM_GRACE_MS = 5000;
|
|
51
|
+
const MUTATION_PROBE_MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
function mutationProbeLeaseMs(timeoutMs) {
|
|
54
|
+
const parsed = Number(timeoutMs);
|
|
55
|
+
const executionMs = Number.isFinite(parsed) && parsed > 0
|
|
56
|
+
? Math.min(parsed, MUTATION_PROBE_MAX_TIMEOUT_MS)
|
|
57
|
+
: MUTATION_PROBE_DEFAULT_TIMEOUT_MS;
|
|
58
|
+
return executionMs + MUTATION_PROBE_RECLAIM_GRACE_MS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mutationProbeExpired(probe, now = Date.now()) {
|
|
62
|
+
const reclaimAfterAt = Number(probe?.reclaimAfterAt);
|
|
63
|
+
if (Number.isFinite(reclaimAfterAt)) return now >= reclaimAfterAt;
|
|
64
|
+
const observedAt = Number(probe?.observedAt);
|
|
65
|
+
return Number.isFinite(observedAt) &&
|
|
66
|
+
now - observedAt >= mutationProbeLeaseMs(null);
|
|
67
|
+
}
|
|
48
68
|
|
|
49
69
|
function mutationProbeDir(context) {
|
|
50
70
|
return path.join(context.sessionDir, 'mutation-probes');
|
|
@@ -81,15 +101,25 @@ function withMutationProbeLock(context, callback) {
|
|
|
81
101
|
finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
function probeCountsIteration(probe) {
|
|
105
|
+
return Boolean(probe && (probe.countIteration === true ||
|
|
106
|
+
(probe.countIteration === undefined && probe.reserveIteration === true)));
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
function mutationProbeReservations(context) {
|
|
85
110
|
const dir = mutationProbeDir(context);
|
|
86
111
|
let files = [];
|
|
87
112
|
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
88
113
|
catch (_) { return 0; }
|
|
114
|
+
const now = Date.now();
|
|
89
115
|
let count = 0;
|
|
90
116
|
for (const file of files) {
|
|
91
117
|
const probe = readJson(path.join(dir, file));
|
|
92
|
-
if (probe?.schemaVersion
|
|
118
|
+
if (probe?.schemaVersion !== 1 || probe.reserveIteration !== true) continue;
|
|
119
|
+
// A reservation remains active for the host/tool execution timeout plus
|
|
120
|
+
// a grace period. This prevents a genuinely in-flight command from being
|
|
121
|
+
// reclaimed merely because it ran longer than an arbitrary short delay.
|
|
122
|
+
if (!mutationProbeExpired(probe, now)) count++;
|
|
93
123
|
}
|
|
94
124
|
return count;
|
|
95
125
|
}
|
|
@@ -115,22 +145,95 @@ function assertIterationCapacity(context, evidence) {
|
|
|
115
145
|
return { applicable: true, value: budget.counters.iterations, reserved, limit };
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
function
|
|
148
|
+
function reclaimStaleMutationProbes(context, keepKey) {
|
|
149
|
+
const dir = mutationProbeDir(context);
|
|
150
|
+
let files = [];
|
|
151
|
+
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
152
|
+
catch (_) { return; }
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (const name of files) {
|
|
155
|
+
const staleKey = name.slice(0, -'.json'.length);
|
|
156
|
+
if (staleKey === keepKey) continue;
|
|
157
|
+
const file = path.join(dir, name);
|
|
158
|
+
const probe = readJson(file);
|
|
159
|
+
if (!probe || probe.schemaVersion !== 1) continue;
|
|
160
|
+
if (!mutationProbeExpired(probe, now)) continue;
|
|
161
|
+
const countsIteration = probeCountsIteration(probe);
|
|
162
|
+
if (!probe.observationCwd) {
|
|
163
|
+
// Legacy probes predate stored observation roots and cannot be safely
|
|
164
|
+
// re-observed. Once outside the in-flight grace window they must not
|
|
165
|
+
// reserve capacity forever.
|
|
166
|
+
fs.unlinkSync(file);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let currentFingerprint;
|
|
170
|
+
try {
|
|
171
|
+
currentFingerprint = workspaceFingerprint(probe.observationCwd);
|
|
172
|
+
} catch (_) {
|
|
173
|
+
fs.unlinkSync(file);
|
|
174
|
+
context.workflow.lastMutationAt = now;
|
|
175
|
+
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
176
|
+
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-unobservable-workspace' });
|
|
177
|
+
} else {
|
|
178
|
+
saveWorkflow(context);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (currentFingerprint !== probe.fingerprint) {
|
|
183
|
+
fs.unlinkSync(file);
|
|
184
|
+
context.workflow.lastMutationAt = now;
|
|
185
|
+
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
186
|
+
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-stale-workspace-mutation' });
|
|
187
|
+
} else {
|
|
188
|
+
saveWorkflow(context);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// No visible workspace effect after the grace window: this probe was
|
|
193
|
+
// orphaned by a denial/block/missing Post event. Discard it so it cannot
|
|
194
|
+
// reserve capacity or later attribute an unrelated mutation (#161).
|
|
195
|
+
fs.unlinkSync(file);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, options = {}) {
|
|
119
200
|
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
120
201
|
throw new Error('invalid mutation probe identity');
|
|
121
202
|
}
|
|
122
203
|
return withMutationProbeLock(context, () => {
|
|
123
204
|
refreshWorkflowForProbe(context);
|
|
124
|
-
|
|
205
|
+
reclaimStaleMutationProbes(context, key);
|
|
206
|
+
let activeReservation = Boolean(reserveIteration);
|
|
207
|
+
let atLimitAllowance = false;
|
|
208
|
+
if (activeReservation) {
|
|
209
|
+
const budget = ensureWorkflowBudget(context.workflow);
|
|
210
|
+
const limit = budget.limits.maxIterations;
|
|
211
|
+
const reserved = mutationProbeReservations(context);
|
|
212
|
+
if (options.allowAtLimit === true && budget.state === 'active' && limit !== null &&
|
|
213
|
+
budget.counters.iterations === limit && reserved === 0) {
|
|
214
|
+
// Verification-shaped commands get observation-only admission at the
|
|
215
|
+
// exact mutation limit. If they mutate, settlement still records the
|
|
216
|
+
// mutation and exhausts the budget after execution (#164).
|
|
217
|
+
activeReservation = false;
|
|
218
|
+
atLimitAllowance = true;
|
|
219
|
+
} else {
|
|
220
|
+
assertIterationCapacity(context, 'shell:untrusted');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
125
223
|
const dir = mutationProbeDir(context);
|
|
126
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
127
225
|
const file = mutationProbeFile(context, key);
|
|
128
226
|
if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
|
|
227
|
+
const observedAt = Date.now();
|
|
129
228
|
const probe = {
|
|
130
229
|
schemaVersion: 1,
|
|
131
230
|
fingerprint,
|
|
132
|
-
reserveIteration:
|
|
133
|
-
|
|
231
|
+
reserveIteration: activeReservation,
|
|
232
|
+
countIteration: options.countIteration === undefined ? Boolean(reserveIteration) : Boolean(options.countIteration),
|
|
233
|
+
atLimitAllowance,
|
|
234
|
+
observationCwd: observationCwd ? path.resolve(observationCwd) : null,
|
|
235
|
+
observedAt,
|
|
236
|
+
reclaimAfterAt: observedAt + mutationProbeLeaseMs(options.timeoutMs),
|
|
134
237
|
};
|
|
135
238
|
atomicWriteJson(file, probe);
|
|
136
239
|
return probe;
|
|
@@ -152,7 +255,7 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
|
|
|
152
255
|
const changed = afterFingerprint !== probe.fingerprint;
|
|
153
256
|
if (changed) {
|
|
154
257
|
context.workflow.lastMutationAt = Date.now();
|
|
155
|
-
if (probe
|
|
258
|
+
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
156
259
|
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
|
|
157
260
|
} else {
|
|
158
261
|
saveWorkflow(context);
|
|
@@ -170,7 +273,7 @@ function settleMutationProbeConservative(context, key, tool, evidence = 'unobser
|
|
|
170
273
|
refreshWorkflowForProbe(context);
|
|
171
274
|
fs.unlinkSync(file);
|
|
172
275
|
context.workflow.lastMutationAt = Date.now();
|
|
173
|
-
if (probe
|
|
276
|
+
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
174
277
|
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
|
|
175
278
|
} else {
|
|
176
279
|
saveWorkflow(context);
|
|
@@ -3,14 +3,9 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
|
|
5
5
|
const { loadWorkflow, saveWorkflow, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
|
|
6
|
-
const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
6
|
+
const { cwdOf, shellProbeKey, isVerificationShell, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
7
7
|
const { observeTool } = require('./lib/telemetry');
|
|
8
8
|
|
|
9
|
-
// Commands that count as "verification ran" for the Stop gate
|
|
10
|
-
// (hooks/scripts/stop-gate.js). Recall over precision: under-blocking the
|
|
11
|
-
// gate is the correct failure direction, so a broad net is fine.
|
|
12
|
-
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
13
|
-
|
|
14
9
|
let inputData = '';
|
|
15
10
|
const timeout = setTimeout(() => {
|
|
16
11
|
processState(null);
|
|
@@ -37,10 +32,12 @@ function observeWorkspaceMutation(payload, root) {
|
|
|
37
32
|
if (!context.workflow || context.workflow.state === 'deferred') return false;
|
|
38
33
|
const cwd = cwdOf(payload, root);
|
|
39
34
|
const probeKey = shellProbeKey(payload, cwd);
|
|
40
|
-
|
|
35
|
+
const probe = peekMutationProbe(context, probeKey);
|
|
36
|
+
if (!probe) return false;
|
|
37
|
+
const observationCwd = probe.observationCwd || cwd;
|
|
41
38
|
let after;
|
|
42
39
|
try {
|
|
43
|
-
after = workspaceFingerprint(
|
|
40
|
+
after = workspaceFingerprint(observationCwd);
|
|
44
41
|
} catch (error) {
|
|
45
42
|
if (!isMajorWorkflow(context.workflow)) {
|
|
46
43
|
const conservative = settleMutationProbeConservative(context, probeKey, toolName, 'unobservable-workspace');
|
|
@@ -125,7 +122,7 @@ function processState(payload) {
|
|
|
125
122
|
}
|
|
126
123
|
if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
|
|
127
124
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
128
|
-
if (
|
|
125
|
+
if (isVerificationShell(command)) {
|
|
129
126
|
state.lastVerifyAt = Date.now();
|
|
130
127
|
state.lastVerifyExitCode = exitCode ?? null;
|
|
131
128
|
}
|
|
@@ -4,11 +4,20 @@
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
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, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
|
|
7
|
+
const { key, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, 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']);
|
|
11
11
|
|
|
12
|
+
function shellTimeoutMs(payload) {
|
|
13
|
+
const input = payload?.tool_input || payload?.input || {};
|
|
14
|
+
for (const value of [input.timeout_ms, input.timeoutMs, input.timeout, payload.timeout_ms, payload.timeoutMs]) {
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
function isController(command, cwd) {
|
|
13
22
|
// One trusted local executable, literal arguments, no shell evaluation.
|
|
14
23
|
if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
|
|
@@ -79,7 +88,12 @@ function decide(payload) {
|
|
|
79
88
|
return;
|
|
80
89
|
}
|
|
81
90
|
const probeKey = shellProbeKey(payload, cwd);
|
|
82
|
-
|
|
91
|
+
const iterative = workflow.strategy === 'iterative-single';
|
|
92
|
+
registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
|
|
93
|
+
countIteration: iterative,
|
|
94
|
+
allowAtLimit: iterative && isVerificationShell(command),
|
|
95
|
+
timeoutMs: shellTimeoutMs(payload),
|
|
96
|
+
});
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
readHookInput(decide);
|
package/package.json
CHANGED
|
@@ -76,10 +76,18 @@ function workspaceFingerprint(cwd) {
|
|
|
76
76
|
function shellProbeKey(payload, cwd) {
|
|
77
77
|
const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
|
|
78
78
|
const tool = String(payload?.tool_name || payload?.tool || '');
|
|
79
|
+
if (toolUseId) {
|
|
80
|
+
// Host tool-use identity is stable across Pre/Post payload cwd drift.
|
|
81
|
+
return crypto.createHash('sha256')
|
|
82
|
+
.update('harness-shell-probe-v3\0')
|
|
83
|
+
.update(toolUseId).update('\0')
|
|
84
|
+
.update(tool)
|
|
85
|
+
.digest('hex');
|
|
86
|
+
}
|
|
87
|
+
// Compatibility fallback for hosts that do not expose tool_use_id.
|
|
79
88
|
const command = commandOf(payload);
|
|
80
89
|
return crypto.createHash('sha256')
|
|
81
|
-
.update('harness-shell-probe-v2\0')
|
|
82
|
-
.update(toolUseId || 'no-tool-use-id').update('\0')
|
|
90
|
+
.update('harness-shell-probe-v2-fallback\0')
|
|
83
91
|
.update(tool).update('\0')
|
|
84
92
|
.update(key(cwd)).update('\0')
|
|
85
93
|
.update(command)
|
|
@@ -127,6 +135,11 @@ function linkedWorktree(cwd, boundRoot) {
|
|
|
127
135
|
// Unsupported shell syntax still fails closed.
|
|
128
136
|
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;
|
|
129
137
|
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;
|
|
138
|
+
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
139
|
+
|
|
140
|
+
function isVerificationShell(command) {
|
|
141
|
+
return VERIFY_COMMAND_RE.test(String(command || ''));
|
|
142
|
+
}
|
|
130
143
|
|
|
131
144
|
function isReadOnlySegment(segment) {
|
|
132
145
|
return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
|
|
@@ -303,4 +316,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
303
316
|
}
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
319
|
+
module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getWorkspaceRoot, getSessionDir, getSessionId, getStateRoot } = require('./harness-state');
|
|
6
6
|
const { atomicWriteJson, readJson } = require('./fable-contracts');
|
|
7
|
+
const { workspaceFingerprint } = require('./workflow-isolation');
|
|
7
8
|
|
|
8
9
|
const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
|
|
9
10
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -45,6 +46,25 @@ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow)
|
|
|
45
46
|
|
|
46
47
|
const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
|
|
47
48
|
const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
49
|
+
const MUTATION_PROBE_DEFAULT_TIMEOUT_MS = 120000;
|
|
50
|
+
const MUTATION_PROBE_RECLAIM_GRACE_MS = 5000;
|
|
51
|
+
const MUTATION_PROBE_MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
function mutationProbeLeaseMs(timeoutMs) {
|
|
54
|
+
const parsed = Number(timeoutMs);
|
|
55
|
+
const executionMs = Number.isFinite(parsed) && parsed > 0
|
|
56
|
+
? Math.min(parsed, MUTATION_PROBE_MAX_TIMEOUT_MS)
|
|
57
|
+
: MUTATION_PROBE_DEFAULT_TIMEOUT_MS;
|
|
58
|
+
return executionMs + MUTATION_PROBE_RECLAIM_GRACE_MS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mutationProbeExpired(probe, now = Date.now()) {
|
|
62
|
+
const reclaimAfterAt = Number(probe?.reclaimAfterAt);
|
|
63
|
+
if (Number.isFinite(reclaimAfterAt)) return now >= reclaimAfterAt;
|
|
64
|
+
const observedAt = Number(probe?.observedAt);
|
|
65
|
+
return Number.isFinite(observedAt) &&
|
|
66
|
+
now - observedAt >= mutationProbeLeaseMs(null);
|
|
67
|
+
}
|
|
48
68
|
|
|
49
69
|
function mutationProbeDir(context) {
|
|
50
70
|
return path.join(context.sessionDir, 'mutation-probes');
|
|
@@ -81,15 +101,25 @@ function withMutationProbeLock(context, callback) {
|
|
|
81
101
|
finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
function probeCountsIteration(probe) {
|
|
105
|
+
return Boolean(probe && (probe.countIteration === true ||
|
|
106
|
+
(probe.countIteration === undefined && probe.reserveIteration === true)));
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
function mutationProbeReservations(context) {
|
|
85
110
|
const dir = mutationProbeDir(context);
|
|
86
111
|
let files = [];
|
|
87
112
|
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
88
113
|
catch (_) { return 0; }
|
|
114
|
+
const now = Date.now();
|
|
89
115
|
let count = 0;
|
|
90
116
|
for (const file of files) {
|
|
91
117
|
const probe = readJson(path.join(dir, file));
|
|
92
|
-
if (probe?.schemaVersion
|
|
118
|
+
if (probe?.schemaVersion !== 1 || probe.reserveIteration !== true) continue;
|
|
119
|
+
// A reservation remains active for the host/tool execution timeout plus
|
|
120
|
+
// a grace period. This prevents a genuinely in-flight command from being
|
|
121
|
+
// reclaimed merely because it ran longer than an arbitrary short delay.
|
|
122
|
+
if (!mutationProbeExpired(probe, now)) count++;
|
|
93
123
|
}
|
|
94
124
|
return count;
|
|
95
125
|
}
|
|
@@ -115,22 +145,95 @@ function assertIterationCapacity(context, evidence) {
|
|
|
115
145
|
return { applicable: true, value: budget.counters.iterations, reserved, limit };
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
function
|
|
148
|
+
function reclaimStaleMutationProbes(context, keepKey) {
|
|
149
|
+
const dir = mutationProbeDir(context);
|
|
150
|
+
let files = [];
|
|
151
|
+
try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
|
|
152
|
+
catch (_) { return; }
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (const name of files) {
|
|
155
|
+
const staleKey = name.slice(0, -'.json'.length);
|
|
156
|
+
if (staleKey === keepKey) continue;
|
|
157
|
+
const file = path.join(dir, name);
|
|
158
|
+
const probe = readJson(file);
|
|
159
|
+
if (!probe || probe.schemaVersion !== 1) continue;
|
|
160
|
+
if (!mutationProbeExpired(probe, now)) continue;
|
|
161
|
+
const countsIteration = probeCountsIteration(probe);
|
|
162
|
+
if (!probe.observationCwd) {
|
|
163
|
+
// Legacy probes predate stored observation roots and cannot be safely
|
|
164
|
+
// re-observed. Once outside the in-flight grace window they must not
|
|
165
|
+
// reserve capacity forever.
|
|
166
|
+
fs.unlinkSync(file);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let currentFingerprint;
|
|
170
|
+
try {
|
|
171
|
+
currentFingerprint = workspaceFingerprint(probe.observationCwd);
|
|
172
|
+
} catch (_) {
|
|
173
|
+
fs.unlinkSync(file);
|
|
174
|
+
context.workflow.lastMutationAt = now;
|
|
175
|
+
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
176
|
+
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-unobservable-workspace' });
|
|
177
|
+
} else {
|
|
178
|
+
saveWorkflow(context);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (currentFingerprint !== probe.fingerprint) {
|
|
183
|
+
fs.unlinkSync(file);
|
|
184
|
+
context.workflow.lastMutationAt = now;
|
|
185
|
+
if (countsIteration && context.workflow.strategy === 'iterative-single') {
|
|
186
|
+
recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-stale-workspace-mutation' });
|
|
187
|
+
} else {
|
|
188
|
+
saveWorkflow(context);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// No visible workspace effect after the grace window: this probe was
|
|
193
|
+
// orphaned by a denial/block/missing Post event. Discard it so it cannot
|
|
194
|
+
// reserve capacity or later attribute an unrelated mutation (#161).
|
|
195
|
+
fs.unlinkSync(file);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, options = {}) {
|
|
119
200
|
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
120
201
|
throw new Error('invalid mutation probe identity');
|
|
121
202
|
}
|
|
122
203
|
return withMutationProbeLock(context, () => {
|
|
123
204
|
refreshWorkflowForProbe(context);
|
|
124
|
-
|
|
205
|
+
reclaimStaleMutationProbes(context, key);
|
|
206
|
+
let activeReservation = Boolean(reserveIteration);
|
|
207
|
+
let atLimitAllowance = false;
|
|
208
|
+
if (activeReservation) {
|
|
209
|
+
const budget = ensureWorkflowBudget(context.workflow);
|
|
210
|
+
const limit = budget.limits.maxIterations;
|
|
211
|
+
const reserved = mutationProbeReservations(context);
|
|
212
|
+
if (options.allowAtLimit === true && budget.state === 'active' && limit !== null &&
|
|
213
|
+
budget.counters.iterations === limit && reserved === 0) {
|
|
214
|
+
// Verification-shaped commands get observation-only admission at the
|
|
215
|
+
// exact mutation limit. If they mutate, settlement still records the
|
|
216
|
+
// mutation and exhausts the budget after execution (#164).
|
|
217
|
+
activeReservation = false;
|
|
218
|
+
atLimitAllowance = true;
|
|
219
|
+
} else {
|
|
220
|
+
assertIterationCapacity(context, 'shell:untrusted');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
125
223
|
const dir = mutationProbeDir(context);
|
|
126
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
127
225
|
const file = mutationProbeFile(context, key);
|
|
128
226
|
if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
|
|
227
|
+
const observedAt = Date.now();
|
|
129
228
|
const probe = {
|
|
130
229
|
schemaVersion: 1,
|
|
131
230
|
fingerprint,
|
|
132
|
-
reserveIteration:
|
|
133
|
-
|
|
231
|
+
reserveIteration: activeReservation,
|
|
232
|
+
countIteration: options.countIteration === undefined ? Boolean(reserveIteration) : Boolean(options.countIteration),
|
|
233
|
+
atLimitAllowance,
|
|
234
|
+
observationCwd: observationCwd ? path.resolve(observationCwd) : null,
|
|
235
|
+
observedAt,
|
|
236
|
+
reclaimAfterAt: observedAt + mutationProbeLeaseMs(options.timeoutMs),
|
|
134
237
|
};
|
|
135
238
|
atomicWriteJson(file, probe);
|
|
136
239
|
return probe;
|
|
@@ -152,7 +255,7 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
|
|
|
152
255
|
const changed = afterFingerprint !== probe.fingerprint;
|
|
153
256
|
if (changed) {
|
|
154
257
|
context.workflow.lastMutationAt = Date.now();
|
|
155
|
-
if (probe
|
|
258
|
+
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
156
259
|
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
|
|
157
260
|
} else {
|
|
158
261
|
saveWorkflow(context);
|
|
@@ -170,7 +273,7 @@ function settleMutationProbeConservative(context, key, tool, evidence = 'unobser
|
|
|
170
273
|
refreshWorkflowForProbe(context);
|
|
171
274
|
fs.unlinkSync(file);
|
|
172
275
|
context.workflow.lastMutationAt = Date.now();
|
|
173
|
-
if (probe
|
|
276
|
+
if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
|
|
174
277
|
recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
|
|
175
278
|
} else {
|
|
176
279
|
saveWorkflow(context);
|
|
@@ -3,14 +3,9 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
|
|
5
5
|
const { loadWorkflow, saveWorkflow, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
|
|
6
|
-
const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
6
|
+
const { cwdOf, shellProbeKey, isVerificationShell, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
|
|
7
7
|
const { observeTool } = require('./lib/telemetry');
|
|
8
8
|
|
|
9
|
-
// Commands that count as "verification ran" for the Stop gate
|
|
10
|
-
// (hooks/scripts/stop-gate.js). Recall over precision: under-blocking the
|
|
11
|
-
// gate is the correct failure direction, so a broad net is fine.
|
|
12
|
-
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
13
|
-
|
|
14
9
|
let inputData = '';
|
|
15
10
|
const timeout = setTimeout(() => {
|
|
16
11
|
processState(null);
|
|
@@ -37,10 +32,12 @@ function observeWorkspaceMutation(payload, root) {
|
|
|
37
32
|
if (!context.workflow || context.workflow.state === 'deferred') return false;
|
|
38
33
|
const cwd = cwdOf(payload, root);
|
|
39
34
|
const probeKey = shellProbeKey(payload, cwd);
|
|
40
|
-
|
|
35
|
+
const probe = peekMutationProbe(context, probeKey);
|
|
36
|
+
if (!probe) return false;
|
|
37
|
+
const observationCwd = probe.observationCwd || cwd;
|
|
41
38
|
let after;
|
|
42
39
|
try {
|
|
43
|
-
after = workspaceFingerprint(
|
|
40
|
+
after = workspaceFingerprint(observationCwd);
|
|
44
41
|
} catch (error) {
|
|
45
42
|
if (!isMajorWorkflow(context.workflow)) {
|
|
46
43
|
const conservative = settleMutationProbeConservative(context, probeKey, toolName, 'unobservable-workspace');
|
|
@@ -125,7 +122,7 @@ function processState(payload) {
|
|
|
125
122
|
}
|
|
126
123
|
if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
|
|
127
124
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
128
|
-
if (
|
|
125
|
+
if (isVerificationShell(command)) {
|
|
129
126
|
state.lastVerifyAt = Date.now();
|
|
130
127
|
state.lastVerifyExitCode = exitCode ?? null;
|
|
131
128
|
}
|
|
@@ -4,11 +4,20 @@
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
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, directMutationInWorkspace, linkedWorktree, assertTargets, assertShellScope } = require('./lib/workflow-isolation');
|
|
7
|
+
const { key, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, cwdOf, commandOf, mutationPaths, directMutationInWorkspace, 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']);
|
|
11
11
|
|
|
12
|
+
function shellTimeoutMs(payload) {
|
|
13
|
+
const input = payload?.tool_input || payload?.input || {};
|
|
14
|
+
for (const value of [input.timeout_ms, input.timeoutMs, input.timeout, payload.timeout_ms, payload.timeoutMs]) {
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
function isController(command, cwd) {
|
|
13
22
|
// One trusted local executable, literal arguments, no shell evaluation.
|
|
14
23
|
if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
|
|
@@ -79,7 +88,12 @@ function decide(payload) {
|
|
|
79
88
|
return;
|
|
80
89
|
}
|
|
81
90
|
const probeKey = shellProbeKey(payload, cwd);
|
|
82
|
-
|
|
91
|
+
const iterative = workflow.strategy === 'iterative-single';
|
|
92
|
+
registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
|
|
93
|
+
countIteration: iterative,
|
|
94
|
+
allowAtLimit: iterative && isVerificationShell(command),
|
|
95
|
+
timeoutMs: shellTimeoutMs(payload),
|
|
96
|
+
});
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
readHookInput(decide);
|
|
@@ -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.12.
|
|
4
|
+
"version": "0.12.2",
|
|
5
5
|
"description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Miya Daniel",
|