harness-everything 0.12.1 → 0.12.3
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 +23 -0
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +52 -0
- package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +216 -0
- package/hooks/scripts/lib/workflow-isolation.js +88 -26
- package/hooks/scripts/lib/workflow-runtime.js +22 -5
- package/hooks/scripts/state-persist.js +6 -9
- package/hooks/scripts/workflow-gate.js +11 -11
- 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 +88 -26
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +22 -5
- package/plugins/harness-everything/hooks/scripts/state-persist.js +6 -9
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +11 -11
- 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.3",
|
|
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.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.12.3](https://github.com/dyphn1/Harness-everything/compare/v0.12.2...v0.12.3) (2026-09-19)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **workflow:** degrade oversized fingerprints conservatively ([ea90f30](https://github.com/dyphn1/Harness-everything/commit/ea90f30a4495e6144017373463c5279fae563252))
|
|
10
|
+
* **workflow:** degrade oversized fingerprints conservatively ([a618d22](https://github.com/dyphn1/Harness-everything/commit/a618d2216955ab3594ecb31b45635dea94e9b109))
|
|
11
|
+
* **workflow:** settle oversized fingerprint probes conservatively ([aafce50](https://github.com/dyphn1/Harness-everything/commit/aafce508551d6564afc884da17555ed71f989836))
|
|
12
|
+
* **workflow:** settle oversized fingerprint probes conservatively ([3092dd6](https://github.com/dyphn1/Harness-everything/commit/3092dd60ed19de97c096f57278c26813f77c3ff7))
|
|
13
|
+
|
|
14
|
+
### Performance Improvements
|
|
15
|
+
|
|
16
|
+
* **workflow:** batch workspace content hashing ([ad0fc18](https://github.com/dyphn1/Harness-everything/commit/ad0fc18260c5a716d7711cb4b83cf9f5059dd21d))
|
|
17
|
+
* **workflow:** batch workspace content hashing ([2cdd6e3](https://github.com/dyphn1/Harness-everything/commit/2cdd6e3dc568c9d6007ca10de76b06b47ff39880))
|
|
18
|
+
|
|
19
|
+
## [0.12.2](https://github.com/dyphn1/Harness-everything/compare/v0.12.1...v0.12.2) (2026-09-19)
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* **workflow:** admit observation-only verification at limit ([08b859e](https://github.com/dyphn1/Harness-everything/commit/08b859ef27c04a07d9a0a6faeeed52f3da986378))
|
|
24
|
+
* **workflow:** admit observation-only verification at limit ([8924a3c](https://github.com/dyphn1/Harness-everything/commit/8924a3cc80455592accbe81e3f2df0f4479a2d8b))
|
|
25
|
+
* **workflow:** allow verification probe at iteration limit ([7ce511e](https://github.com/dyphn1/Harness-everything/commit/7ce511e29ca368371d996f4cbeff02298d97434c))
|
|
26
|
+
* **workflow:** allow verification probe at iteration limit ([07e6ed0](https://github.com/dyphn1/Harness-everything/commit/07e6ed0b369e38f274ac099af590b12373b1ad1c))
|
|
27
|
+
|
|
5
28
|
## [0.12.1](https://github.com/dyphn1/Harness-everything/compare/v0.12.0...v0.12.1) (2026-09-19)
|
|
6
29
|
|
|
7
30
|
### Bug Fixes
|
|
@@ -203,6 +203,58 @@ try {
|
|
|
203
203
|
git(['init']); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); git(['add', '.']);
|
|
204
204
|
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
|
|
205
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
|
+
|
|
206
258
|
const taskNotification = '<task-notification><summary>Background command "Run full test suite" failed with exit code 1</summary></task-notification>';
|
|
207
259
|
const unboundNotification = node('harness-everything/scripts/kernel-router.js', { cwd: repo, prompt: taskNotification });
|
|
208
260
|
check(unboundNotification.status === 0 &&
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
12
|
+
const isolation = require(path.join(ROOT, 'hooks/scripts/lib/workflow-isolation'));
|
|
13
|
+
const pluginIsolation = require(path.join(ROOT, 'plugins/harness-everything/hooks/scripts/lib/workflow-isolation'));
|
|
14
|
+
const state = require(path.join(ROOT, 'hooks/scripts/lib/harness-state'));
|
|
15
|
+
|
|
16
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-workspace-fingerprint-'));
|
|
17
|
+
const repo = path.join(temp, 'repo');
|
|
18
|
+
fs.mkdirSync(repo);
|
|
19
|
+
const env = {
|
|
20
|
+
...process.env,
|
|
21
|
+
HARNESS_STATE_HOME: path.join(temp, 'state'),
|
|
22
|
+
HARNESS_WORKSPACE_ROOT: repo,
|
|
23
|
+
};
|
|
24
|
+
process.env.HARNESS_STATE_HOME = env.HARNESS_STATE_HOME;
|
|
25
|
+
process.env.HARNESS_WORKSPACE_ROOT = env.HARNESS_WORKSPACE_ROOT;
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
function check(value, message) {
|
|
29
|
+
assert.ok(value, message);
|
|
30
|
+
passed++;
|
|
31
|
+
console.log('PASS ' + message);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function run(command, args, options = {}) {
|
|
35
|
+
const result = spawnSync(command, args, {
|
|
36
|
+
cwd: options.cwd || repo,
|
|
37
|
+
env,
|
|
38
|
+
input: options.input,
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
42
|
+
});
|
|
43
|
+
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
|
44
|
+
return result.stdout;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function git(args) {
|
|
48
|
+
return run('git', args);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function node(script, payload, args = []) {
|
|
52
|
+
return spawnSync(process.execPath, [path.join(ROOT, script), ...args], {
|
|
53
|
+
cwd: repo,
|
|
54
|
+
env,
|
|
55
|
+
input: payload === undefined ? undefined : JSON.stringify(payload),
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
windowsHide: true,
|
|
58
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function gitRaw(args) {
|
|
63
|
+
return run('git', args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function legacyHashWorktreePath(root, relative) {
|
|
67
|
+
const target = path.join(root, relative);
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = fs.lstatSync(target);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT') return null;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (stat.isDirectory()) {
|
|
76
|
+
const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
});
|
|
80
|
+
return { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' };
|
|
81
|
+
}
|
|
82
|
+
const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
|
|
83
|
+
const result = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
|
|
84
|
+
cwd: root,
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
if (result.status !== 0) throw new Error('legacy fingerprint failed for ' + relative);
|
|
89
|
+
return { mode, oid: result.stdout.trim() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function legacyFingerprint(cwd) {
|
|
93
|
+
const root = isolation.canonical(gitRaw(['rev-parse', '--show-toplevel']).trim());
|
|
94
|
+
const entries = new Map();
|
|
95
|
+
for (const record of gitRaw(['ls-files', '-s', '-z']).split('\0')) {
|
|
96
|
+
if (!record) continue;
|
|
97
|
+
const tab = record.indexOf('\t');
|
|
98
|
+
assert.ok(tab >= 0, 'legacy index record has a tab');
|
|
99
|
+
const meta = record.slice(0, tab).trim().split(/\s+/);
|
|
100
|
+
const relative = record.slice(tab + 1);
|
|
101
|
+
entries.set(relative, { relative, mode: meta[0], oid: meta[1] });
|
|
102
|
+
}
|
|
103
|
+
for (const relative of gitRaw(['diff-files', '--name-only', '-z']).split('\0').filter(Boolean)) {
|
|
104
|
+
const value = legacyHashWorktreePath(root, relative);
|
|
105
|
+
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
106
|
+
else entries.delete(relative);
|
|
107
|
+
}
|
|
108
|
+
for (const relative of gitRaw(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
|
|
109
|
+
const value = legacyHashWorktreePath(root, relative);
|
|
110
|
+
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
111
|
+
}
|
|
112
|
+
const digest = crypto.createHash('sha256');
|
|
113
|
+
digest.update('harness-workspace-fingerprint-v1\0');
|
|
114
|
+
for (const entry of [...entries.values()].sort((a, b) => a.relative.localeCompare(b.relative))) {
|
|
115
|
+
digest.update(entry.relative); digest.update('\0');
|
|
116
|
+
digest.update(entry.mode); digest.update('\0');
|
|
117
|
+
digest.update(entry.oid); digest.update('\0');
|
|
118
|
+
}
|
|
119
|
+
return digest.digest('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
git(['init']);
|
|
124
|
+
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'tracked\n');
|
|
125
|
+
fs.writeFileSync(path.join(repo, 'delete-me.txt'), 'delete me\n');
|
|
126
|
+
git(['add', '.']);
|
|
127
|
+
git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'fixture']);
|
|
128
|
+
|
|
129
|
+
const cleanLegacy = legacyFingerprint(repo);
|
|
130
|
+
check(isolation.workspaceFingerprint(repo) === cleanLegacy,
|
|
131
|
+
'#169 batched fingerprint matches the legacy clean-worktree fingerprint');
|
|
132
|
+
check(pluginIsolation.workspaceFingerprint(repo) === cleanLegacy,
|
|
133
|
+
'#169 packaged plugin fingerprint matches canonical/legacy on a clean worktree');
|
|
134
|
+
|
|
135
|
+
fs.writeFileSync(path.join(repo, 'tracked.txt'), 'tracked changed\n');
|
|
136
|
+
fs.writeFileSync(path.join(repo, 'untracked.txt'), 'untracked\n');
|
|
137
|
+
const dirtyLegacy = legacyFingerprint(repo);
|
|
138
|
+
const dirtyBatched = isolation.workspaceFingerprint(repo);
|
|
139
|
+
check(dirtyBatched === dirtyLegacy,
|
|
140
|
+
'#169 batched fingerprint matches legacy for dirty tracked + untracked content');
|
|
141
|
+
check(pluginIsolation.workspaceFingerprint(repo) === dirtyLegacy,
|
|
142
|
+
'#169 packaged plugin batched fingerprint matches legacy dirty semantics');
|
|
143
|
+
|
|
144
|
+
git(['add', 'tracked.txt', 'untracked.txt']);
|
|
145
|
+
check(isolation.workspaceFingerprint(repo) === dirtyBatched,
|
|
146
|
+
'#169 staging visible content does not change the workspace fingerprint');
|
|
147
|
+
|
|
148
|
+
fs.unlinkSync(path.join(repo, 'delete-me.txt'));
|
|
149
|
+
check(isolation.workspaceFingerprint(repo) === legacyFingerprint(repo),
|
|
150
|
+
'#169 tracked deletion keeps legacy fingerprint semantics');
|
|
151
|
+
|
|
152
|
+
git(['reset', '--hard', 'HEAD']);
|
|
153
|
+
git(['clean', '-fdx']);
|
|
154
|
+
const bulk = path.join(repo, 'bulk');
|
|
155
|
+
fs.mkdirSync(bulk);
|
|
156
|
+
for (let i = 0; i < 1000; i++) {
|
|
157
|
+
fs.writeFileSync(path.join(bulk, 'file-' + String(i).padStart(4, '0') + '.txt'), 'x\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const startedAt = process.hrtime.bigint();
|
|
161
|
+
const bulkFingerprint = isolation.workspaceFingerprint(repo);
|
|
162
|
+
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
163
|
+
check(/^[a-f0-9]{64}$/.test(bulkFingerprint),
|
|
164
|
+
'#169 1000-untracked-file fingerprint returns a valid digest');
|
|
165
|
+
check(elapsedMs < 5000,
|
|
166
|
+
'#169 1000-untracked-file fingerprint stays below 5s regression ceiling (actual ' + elapsedMs.toFixed(1) + 'ms)');
|
|
167
|
+
check(pluginIsolation.workspaceFingerprint(repo) === bulkFingerprint,
|
|
168
|
+
'#169 packaged plugin fingerprint remains byte-identical for 1000 untracked files');
|
|
169
|
+
|
|
170
|
+
let limitError = null;
|
|
171
|
+
try {
|
|
172
|
+
isolation.workspaceFingerprint(repo, { maxPaths: 10 });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
limitError = error;
|
|
175
|
+
}
|
|
176
|
+
check(limitError?.code === 'HARNESS_FINGERPRINT_LIMIT' &&
|
|
177
|
+
limitError.count === 1000 &&
|
|
178
|
+
limitError.maxPaths === 10,
|
|
179
|
+
'#169 explicit fingerprint cap reports a structured conservative-fallback signal');
|
|
180
|
+
|
|
181
|
+
for (let i = 1000; i < 5101; i++) {
|
|
182
|
+
fs.writeFileSync(path.join(bulk, 'file-' + String(i).padStart(4, '0') + '.txt'), 'x\n');
|
|
183
|
+
}
|
|
184
|
+
const sessionId = 'workspace-fingerprint-limit';
|
|
185
|
+
const payload = { session_id: sessionId, cwd: repo };
|
|
186
|
+
const routed = node('harness-everything/scripts/kernel-router.js', {
|
|
187
|
+
...payload,
|
|
188
|
+
prompt: 'Fix this checkout bug with a regression test',
|
|
189
|
+
});
|
|
190
|
+
check(routed.status === 0, '#169 large-workspace Tier-2 fixture routes normally');
|
|
191
|
+
const started = node('hooks/scripts/workflow-disposition.js', undefined, ['start', '--session-id', sessionId]);
|
|
192
|
+
check(started.status === 0, '#169 large-workspace Tier-2 fixture starts normally');
|
|
193
|
+
|
|
194
|
+
const admitted = node('hooks/scripts/workflow-gate.js', {
|
|
195
|
+
...payload,
|
|
196
|
+
tool_name: 'Bash',
|
|
197
|
+
tool_use_id: 'toolu_fingerprint_limit',
|
|
198
|
+
tool_input: { command: 'node large-fingerprint.js' },
|
|
199
|
+
});
|
|
200
|
+
check(admitted.status === 0,
|
|
201
|
+
'#169 >5000 observed paths degrade conservatively instead of blocking the shell call');
|
|
202
|
+
const workflowFile = path.join(state.getSessionDir(repo, sessionId), 'workflow-run.json');
|
|
203
|
+
const workflow = JSON.parse(fs.readFileSync(workflowFile, 'utf8'));
|
|
204
|
+
check(workflow.state === 'running' &&
|
|
205
|
+
workflow.budget?.counters?.iterations === 1 &&
|
|
206
|
+
workflow.lastMutationAt > 0 &&
|
|
207
|
+
workflow.budget?.events?.at(-1)?.evidence === 'shell:fingerprint-limit',
|
|
208
|
+
'#169 fingerprint-limit fallback records one auditable conservative mutation');
|
|
209
|
+
|
|
210
|
+
console.log('PASS: batched workspace fingerprint (' + passed + ' assertions, 1000-file ' + elapsedMs.toFixed(1) + 'ms)');
|
|
211
|
+
} finally {
|
|
212
|
+
if (!temp.startsWith(path.join(os.tmpdir(), 'harness-workspace-fingerprint-'))) {
|
|
213
|
+
throw new Error('unsafe fixture cleanup');
|
|
214
|
+
}
|
|
215
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
216
|
+
}
|
|
@@ -5,8 +5,15 @@ const crypto = require('crypto');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
7
|
|
|
8
|
-
function gitRaw(cwd, args) {
|
|
9
|
-
const result = spawnSync('git', args, {
|
|
8
|
+
function gitRaw(cwd, args, options = {}) {
|
|
9
|
+
const result = spawnSync('git', args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
timeout: options.timeout ?? 5000,
|
|
14
|
+
maxBuffer: options.maxBuffer ?? (16 * 1024 * 1024),
|
|
15
|
+
...(options.input === undefined ? {} : { input: options.input }),
|
|
16
|
+
});
|
|
10
17
|
if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
|
|
11
18
|
return result.stdout;
|
|
12
19
|
}
|
|
@@ -15,30 +22,73 @@ function git(cwd, args) {
|
|
|
15
22
|
return gitRaw(cwd, args).trim();
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
function
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
function fingerprintLimitError(count, maxPaths) {
|
|
26
|
+
const error = new Error('workspace fingerprint path limit exceeded');
|
|
27
|
+
error.code = 'HARNESS_FINGERPRINT_LIMIT';
|
|
28
|
+
error.count = count;
|
|
29
|
+
error.maxPaths = maxPaths;
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashWorktreePaths(root, relatives) {
|
|
34
|
+
const values = new Map();
|
|
35
|
+
const batch = [];
|
|
36
|
+
for (const relative of relatives) {
|
|
37
|
+
const target = path.join(root, relative);
|
|
38
|
+
let stat;
|
|
39
|
+
try { stat = fs.lstatSync(target); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT') {
|
|
42
|
+
values.set(relative, null);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
if (stat.isDirectory()) {
|
|
48
|
+
const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], {
|
|
49
|
+
encoding: 'utf8', windowsHide: true, timeout: 5000,
|
|
50
|
+
});
|
|
51
|
+
values.set(relative, { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
|
|
55
|
+
// --stdin-paths is line-delimited. Rare pathnames containing a newline use
|
|
56
|
+
// the legacy one-file call so the fingerprint remains exact.
|
|
57
|
+
if (/[\r\n]/.test(relative)) {
|
|
58
|
+
const one = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
|
|
59
|
+
cwd: root, encoding: 'utf8', windowsHide: true, timeout: 5000,
|
|
60
|
+
});
|
|
61
|
+
if (one.status !== 0) throw new Error('cannot fingerprint workspace content');
|
|
62
|
+
values.set(relative, { mode, oid: one.stdout.trim() });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
batch.push({ relative, mode });
|
|
25
66
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
67
|
+
|
|
68
|
+
if (batch.length > 0) {
|
|
69
|
+
const hashed = spawnSync('git', ['hash-object', '--stdin-paths'], {
|
|
70
|
+
cwd: root,
|
|
71
|
+
input: batch.map(item => item.relative).join('\n') + '\n',
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
timeout: 15000,
|
|
75
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
76
|
+
});
|
|
77
|
+
if (hashed.status !== 0) throw new Error('cannot fingerprint workspace content');
|
|
78
|
+
const oids = hashed.stdout.split(/\r?\n/).filter(Boolean);
|
|
79
|
+
if (oids.length !== batch.length) throw new Error('cannot fingerprint workspace content');
|
|
80
|
+
for (let i = 0; i < batch.length; i++) {
|
|
81
|
+
values.set(batch[i].relative, { mode: batch[i].mode, oid: oids[i] });
|
|
82
|
+
}
|
|
29
83
|
}
|
|
30
|
-
|
|
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() };
|
|
84
|
+
return values;
|
|
36
85
|
}
|
|
37
86
|
|
|
38
|
-
function workspaceFingerprint(cwd) {
|
|
87
|
+
function workspaceFingerprint(cwd, options = {}) {
|
|
39
88
|
const root = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
|
|
40
89
|
const entries = new Map();
|
|
41
|
-
|
|
90
|
+
const largeGitRead = { timeout: 15000, maxBuffer: 64 * 1024 * 1024 };
|
|
91
|
+
for (const record of gitRaw(root, ['ls-files', '-s', '-z'], largeGitRead).split('\0')) {
|
|
42
92
|
if (!record) continue;
|
|
43
93
|
const tab = record.indexOf('\t');
|
|
44
94
|
if (tab < 0) throw new Error('cannot fingerprint workspace index');
|
|
@@ -49,18 +99,25 @@ function workspaceFingerprint(cwd) {
|
|
|
49
99
|
entries.set(relative, { relative, mode, oid });
|
|
50
100
|
}
|
|
51
101
|
|
|
102
|
+
const changed = gitRaw(root, ['diff-files', '--name-only', '-z'], largeGitRead).split('\0').filter(Boolean);
|
|
103
|
+
const untracked = gitRaw(root, ['ls-files', '--others', '--exclude-standard', '-z'], largeGitRead).split('\0').filter(Boolean);
|
|
104
|
+
const observedPaths = [...new Set([...changed, ...untracked])];
|
|
105
|
+
const maxPaths = Number.isInteger(options.maxPaths) && options.maxPaths > 0 ? options.maxPaths : 5000;
|
|
106
|
+
if (observedPaths.length > maxPaths) throw fingerprintLimitError(observedPaths.length, maxPaths);
|
|
107
|
+
const worktreeValues = hashWorktreePaths(root, observedPaths);
|
|
108
|
+
|
|
52
109
|
// Substitute current worktree content for tracked paths that differ from the
|
|
53
110
|
// index. This keeps the fingerprint stable across git add / git commit when
|
|
54
111
|
// visible workspace content did not change.
|
|
55
|
-
for (const relative of
|
|
56
|
-
const value =
|
|
112
|
+
for (const relative of changed) {
|
|
113
|
+
const value = worktreeValues.get(relative);
|
|
57
114
|
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
58
115
|
else entries.delete(relative);
|
|
59
116
|
}
|
|
60
117
|
|
|
61
|
-
for (const relative of
|
|
62
|
-
const value =
|
|
63
|
-
entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
118
|
+
for (const relative of untracked) {
|
|
119
|
+
const value = worktreeValues.get(relative);
|
|
120
|
+
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
64
121
|
}
|
|
65
122
|
|
|
66
123
|
const digest = crypto.createHash('sha256');
|
|
@@ -135,6 +192,11 @@ function linkedWorktree(cwd, boundRoot) {
|
|
|
135
192
|
// Unsupported shell syntax still fails closed.
|
|
136
193
|
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;
|
|
137
194
|
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;
|
|
195
|
+
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
196
|
+
|
|
197
|
+
function isVerificationShell(command) {
|
|
198
|
+
return VERIFY_COMMAND_RE.test(String(command || ''));
|
|
199
|
+
}
|
|
138
200
|
|
|
139
201
|
function isReadOnlySegment(segment) {
|
|
140
202
|
return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
|
|
@@ -311,4 +373,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
311
373
|
}
|
|
312
374
|
}
|
|
313
375
|
|
|
314
|
-
module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
376
|
+
module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
@@ -196,14 +196,30 @@ function reclaimStaleMutationProbes(context, keepKey) {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd,
|
|
199
|
+
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, options = {}) {
|
|
200
200
|
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
201
201
|
throw new Error('invalid mutation probe identity');
|
|
202
202
|
}
|
|
203
203
|
return withMutationProbeLock(context, () => {
|
|
204
204
|
refreshWorkflowForProbe(context);
|
|
205
205
|
reclaimStaleMutationProbes(context, key);
|
|
206
|
-
|
|
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
|
+
}
|
|
207
223
|
const dir = mutationProbeDir(context);
|
|
208
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
209
225
|
const file = mutationProbeFile(context, key);
|
|
@@ -212,11 +228,12 @@ function registerMutationProbe(context, key, fingerprint, reserveIteration, obse
|
|
|
212
228
|
const probe = {
|
|
213
229
|
schemaVersion: 1,
|
|
214
230
|
fingerprint,
|
|
215
|
-
reserveIteration:
|
|
216
|
-
countIteration: Boolean(reserveIteration),
|
|
231
|
+
reserveIteration: activeReservation,
|
|
232
|
+
countIteration: options.countIteration === undefined ? Boolean(reserveIteration) : Boolean(options.countIteration),
|
|
233
|
+
atLimitAllowance,
|
|
217
234
|
observationCwd: observationCwd ? path.resolve(observationCwd) : null,
|
|
218
235
|
observedAt,
|
|
219
|
-
reclaimAfterAt: observedAt + mutationProbeLeaseMs(timeoutMs),
|
|
236
|
+
reclaimAfterAt: observedAt + mutationProbeLeaseMs(options.timeoutMs),
|
|
220
237
|
};
|
|
221
238
|
atomicWriteJson(file, probe);
|
|
222
239
|
return probe;
|
|
@@ -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);
|
|
@@ -44,8 +39,10 @@ function observeWorkspaceMutation(payload, root) {
|
|
|
44
39
|
try {
|
|
45
40
|
after = workspaceFingerprint(observationCwd);
|
|
46
41
|
} catch (error) {
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
|
|
43
|
+
if (!isMajorWorkflow(context.workflow) || fingerprintLimited) {
|
|
44
|
+
const evidence = fingerprintLimited ? 'fingerprint-limit' : 'unobservable-workspace';
|
|
45
|
+
const conservative = settleMutationProbeConservative(context, probeKey, toolName, evidence);
|
|
49
46
|
return Boolean(conservative && conservative.changed);
|
|
50
47
|
}
|
|
51
48
|
context.workflow.state = 'blocked';
|
|
@@ -127,7 +124,7 @@ function processState(payload) {
|
|
|
127
124
|
}
|
|
128
125
|
if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
|
|
129
126
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
130
|
-
if (
|
|
127
|
+
if (isVerificationShell(command)) {
|
|
131
128
|
state.lastVerifyAt = Date.now();
|
|
132
129
|
state.lastVerifyExitCode = exitCode ?? null;
|
|
133
130
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
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']);
|
|
@@ -79,23 +79,23 @@ function decide(payload) {
|
|
|
79
79
|
try {
|
|
80
80
|
fingerprint = workspaceFingerprint(cwd);
|
|
81
81
|
} catch (error) {
|
|
82
|
-
|
|
82
|
+
const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
|
|
83
|
+
if (isMajorWorkflow(workflow) && !fingerprintLimited) throw error;
|
|
84
|
+
const evidence = fingerprintLimited ? 'shell:fingerprint-limit' : 'shell:unobservable-workspace';
|
|
83
85
|
if (workflow.strategy === 'iterative-single') {
|
|
84
|
-
recordBudgetEvent(context, 'iteration', { evidence
|
|
86
|
+
recordBudgetEvent(context, 'iteration', { evidence });
|
|
85
87
|
}
|
|
86
88
|
workflow.lastMutationAt = Date.now();
|
|
87
89
|
saveWorkflow(context);
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
92
|
const probeKey = shellProbeKey(payload, cwd);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
shellTimeoutMs(payload)
|
|
98
|
-
);
|
|
93
|
+
const iterative = workflow.strategy === 'iterative-single';
|
|
94
|
+
registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
|
|
95
|
+
countIteration: iterative,
|
|
96
|
+
allowAtLimit: iterative && isVerificationShell(command),
|
|
97
|
+
timeoutMs: shellTimeoutMs(payload),
|
|
98
|
+
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
readHookInput(decide);
|
package/package.json
CHANGED
|
@@ -5,8 +5,15 @@ const crypto = require('crypto');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
7
|
|
|
8
|
-
function gitRaw(cwd, args) {
|
|
9
|
-
const result = spawnSync('git', args, {
|
|
8
|
+
function gitRaw(cwd, args, options = {}) {
|
|
9
|
+
const result = spawnSync('git', args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
timeout: options.timeout ?? 5000,
|
|
14
|
+
maxBuffer: options.maxBuffer ?? (16 * 1024 * 1024),
|
|
15
|
+
...(options.input === undefined ? {} : { input: options.input }),
|
|
16
|
+
});
|
|
10
17
|
if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
|
|
11
18
|
return result.stdout;
|
|
12
19
|
}
|
|
@@ -15,30 +22,73 @@ function git(cwd, args) {
|
|
|
15
22
|
return gitRaw(cwd, args).trim();
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
function
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
function fingerprintLimitError(count, maxPaths) {
|
|
26
|
+
const error = new Error('workspace fingerprint path limit exceeded');
|
|
27
|
+
error.code = 'HARNESS_FINGERPRINT_LIMIT';
|
|
28
|
+
error.count = count;
|
|
29
|
+
error.maxPaths = maxPaths;
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashWorktreePaths(root, relatives) {
|
|
34
|
+
const values = new Map();
|
|
35
|
+
const batch = [];
|
|
36
|
+
for (const relative of relatives) {
|
|
37
|
+
const target = path.join(root, relative);
|
|
38
|
+
let stat;
|
|
39
|
+
try { stat = fs.lstatSync(target); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT') {
|
|
42
|
+
values.set(relative, null);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
if (stat.isDirectory()) {
|
|
48
|
+
const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], {
|
|
49
|
+
encoding: 'utf8', windowsHide: true, timeout: 5000,
|
|
50
|
+
});
|
|
51
|
+
values.set(relative, { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
|
|
55
|
+
// --stdin-paths is line-delimited. Rare pathnames containing a newline use
|
|
56
|
+
// the legacy one-file call so the fingerprint remains exact.
|
|
57
|
+
if (/[\r\n]/.test(relative)) {
|
|
58
|
+
const one = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
|
|
59
|
+
cwd: root, encoding: 'utf8', windowsHide: true, timeout: 5000,
|
|
60
|
+
});
|
|
61
|
+
if (one.status !== 0) throw new Error('cannot fingerprint workspace content');
|
|
62
|
+
values.set(relative, { mode, oid: one.stdout.trim() });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
batch.push({ relative, mode });
|
|
25
66
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
67
|
+
|
|
68
|
+
if (batch.length > 0) {
|
|
69
|
+
const hashed = spawnSync('git', ['hash-object', '--stdin-paths'], {
|
|
70
|
+
cwd: root,
|
|
71
|
+
input: batch.map(item => item.relative).join('\n') + '\n',
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
timeout: 15000,
|
|
75
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
76
|
+
});
|
|
77
|
+
if (hashed.status !== 0) throw new Error('cannot fingerprint workspace content');
|
|
78
|
+
const oids = hashed.stdout.split(/\r?\n/).filter(Boolean);
|
|
79
|
+
if (oids.length !== batch.length) throw new Error('cannot fingerprint workspace content');
|
|
80
|
+
for (let i = 0; i < batch.length; i++) {
|
|
81
|
+
values.set(batch[i].relative, { mode: batch[i].mode, oid: oids[i] });
|
|
82
|
+
}
|
|
29
83
|
}
|
|
30
|
-
|
|
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() };
|
|
84
|
+
return values;
|
|
36
85
|
}
|
|
37
86
|
|
|
38
|
-
function workspaceFingerprint(cwd) {
|
|
87
|
+
function workspaceFingerprint(cwd, options = {}) {
|
|
39
88
|
const root = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
|
|
40
89
|
const entries = new Map();
|
|
41
|
-
|
|
90
|
+
const largeGitRead = { timeout: 15000, maxBuffer: 64 * 1024 * 1024 };
|
|
91
|
+
for (const record of gitRaw(root, ['ls-files', '-s', '-z'], largeGitRead).split('\0')) {
|
|
42
92
|
if (!record) continue;
|
|
43
93
|
const tab = record.indexOf('\t');
|
|
44
94
|
if (tab < 0) throw new Error('cannot fingerprint workspace index');
|
|
@@ -49,18 +99,25 @@ function workspaceFingerprint(cwd) {
|
|
|
49
99
|
entries.set(relative, { relative, mode, oid });
|
|
50
100
|
}
|
|
51
101
|
|
|
102
|
+
const changed = gitRaw(root, ['diff-files', '--name-only', '-z'], largeGitRead).split('\0').filter(Boolean);
|
|
103
|
+
const untracked = gitRaw(root, ['ls-files', '--others', '--exclude-standard', '-z'], largeGitRead).split('\0').filter(Boolean);
|
|
104
|
+
const observedPaths = [...new Set([...changed, ...untracked])];
|
|
105
|
+
const maxPaths = Number.isInteger(options.maxPaths) && options.maxPaths > 0 ? options.maxPaths : 5000;
|
|
106
|
+
if (observedPaths.length > maxPaths) throw fingerprintLimitError(observedPaths.length, maxPaths);
|
|
107
|
+
const worktreeValues = hashWorktreePaths(root, observedPaths);
|
|
108
|
+
|
|
52
109
|
// Substitute current worktree content for tracked paths that differ from the
|
|
53
110
|
// index. This keeps the fingerprint stable across git add / git commit when
|
|
54
111
|
// visible workspace content did not change.
|
|
55
|
-
for (const relative of
|
|
56
|
-
const value =
|
|
112
|
+
for (const relative of changed) {
|
|
113
|
+
const value = worktreeValues.get(relative);
|
|
57
114
|
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
58
115
|
else entries.delete(relative);
|
|
59
116
|
}
|
|
60
117
|
|
|
61
|
-
for (const relative of
|
|
62
|
-
const value =
|
|
63
|
-
entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
118
|
+
for (const relative of untracked) {
|
|
119
|
+
const value = worktreeValues.get(relative);
|
|
120
|
+
if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
|
|
64
121
|
}
|
|
65
122
|
|
|
66
123
|
const digest = crypto.createHash('sha256');
|
|
@@ -135,6 +192,11 @@ function linkedWorktree(cwd, boundRoot) {
|
|
|
135
192
|
// Unsupported shell syntax still fails closed.
|
|
136
193
|
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;
|
|
137
194
|
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;
|
|
195
|
+
const VERIFY_COMMAND_RE = /\b(test|spec|jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|lint|build|compile|verify|check)\b/i;
|
|
196
|
+
|
|
197
|
+
function isVerificationShell(command) {
|
|
198
|
+
return VERIFY_COMMAND_RE.test(String(command || ''));
|
|
199
|
+
}
|
|
138
200
|
|
|
139
201
|
function isReadOnlySegment(segment) {
|
|
140
202
|
return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
|
|
@@ -311,4 +373,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
|
|
|
311
373
|
}
|
|
312
374
|
}
|
|
313
375
|
|
|
314
|
-
module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
376
|
+
module.exports = { canonical, key, within, linkedWorktree, classifyShell, isVerificationShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, worktreeRoots, directMutationInWorkspace, assertTargets, assertShellScope };
|
|
@@ -196,14 +196,30 @@ function reclaimStaleMutationProbes(context, keepKey) {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd,
|
|
199
|
+
function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, options = {}) {
|
|
200
200
|
if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
|
|
201
201
|
throw new Error('invalid mutation probe identity');
|
|
202
202
|
}
|
|
203
203
|
return withMutationProbeLock(context, () => {
|
|
204
204
|
refreshWorkflowForProbe(context);
|
|
205
205
|
reclaimStaleMutationProbes(context, key);
|
|
206
|
-
|
|
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
|
+
}
|
|
207
223
|
const dir = mutationProbeDir(context);
|
|
208
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
209
225
|
const file = mutationProbeFile(context, key);
|
|
@@ -212,11 +228,12 @@ function registerMutationProbe(context, key, fingerprint, reserveIteration, obse
|
|
|
212
228
|
const probe = {
|
|
213
229
|
schemaVersion: 1,
|
|
214
230
|
fingerprint,
|
|
215
|
-
reserveIteration:
|
|
216
|
-
countIteration: Boolean(reserveIteration),
|
|
231
|
+
reserveIteration: activeReservation,
|
|
232
|
+
countIteration: options.countIteration === undefined ? Boolean(reserveIteration) : Boolean(options.countIteration),
|
|
233
|
+
atLimitAllowance,
|
|
217
234
|
observationCwd: observationCwd ? path.resolve(observationCwd) : null,
|
|
218
235
|
observedAt,
|
|
219
|
-
reclaimAfterAt: observedAt + mutationProbeLeaseMs(timeoutMs),
|
|
236
|
+
reclaimAfterAt: observedAt + mutationProbeLeaseMs(options.timeoutMs),
|
|
220
237
|
};
|
|
221
238
|
atomicWriteJson(file, probe);
|
|
222
239
|
return probe;
|
|
@@ -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);
|
|
@@ -44,8 +39,10 @@ function observeWorkspaceMutation(payload, root) {
|
|
|
44
39
|
try {
|
|
45
40
|
after = workspaceFingerprint(observationCwd);
|
|
46
41
|
} catch (error) {
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
|
|
43
|
+
if (!isMajorWorkflow(context.workflow) || fingerprintLimited) {
|
|
44
|
+
const evidence = fingerprintLimited ? 'fingerprint-limit' : 'unobservable-workspace';
|
|
45
|
+
const conservative = settleMutationProbeConservative(context, probeKey, toolName, evidence);
|
|
49
46
|
return Boolean(conservative && conservative.changed);
|
|
50
47
|
}
|
|
51
48
|
context.workflow.state = 'blocked';
|
|
@@ -127,7 +124,7 @@ function processState(payload) {
|
|
|
127
124
|
}
|
|
128
125
|
if ((toolName === 'Bash' || toolName === 'PowerShell') && !isFailed) {
|
|
129
126
|
const command = (payload.tool_input && payload.tool_input.command) || '';
|
|
130
|
-
if (
|
|
127
|
+
if (isVerificationShell(command)) {
|
|
131
128
|
state.lastVerifyAt = Date.now();
|
|
132
129
|
state.lastVerifyExitCode = exitCode ?? null;
|
|
133
130
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
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']);
|
|
@@ -79,23 +79,23 @@ function decide(payload) {
|
|
|
79
79
|
try {
|
|
80
80
|
fingerprint = workspaceFingerprint(cwd);
|
|
81
81
|
} catch (error) {
|
|
82
|
-
|
|
82
|
+
const fingerprintLimited = error && error.code === 'HARNESS_FINGERPRINT_LIMIT';
|
|
83
|
+
if (isMajorWorkflow(workflow) && !fingerprintLimited) throw error;
|
|
84
|
+
const evidence = fingerprintLimited ? 'shell:fingerprint-limit' : 'shell:unobservable-workspace';
|
|
83
85
|
if (workflow.strategy === 'iterative-single') {
|
|
84
|
-
recordBudgetEvent(context, 'iteration', { evidence
|
|
86
|
+
recordBudgetEvent(context, 'iteration', { evidence });
|
|
85
87
|
}
|
|
86
88
|
workflow.lastMutationAt = Date.now();
|
|
87
89
|
saveWorkflow(context);
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
92
|
const probeKey = shellProbeKey(payload, cwd);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
shellTimeoutMs(payload)
|
|
98
|
-
);
|
|
93
|
+
const iterative = workflow.strategy === 'iterative-single';
|
|
94
|
+
registerMutationProbe(context, probeKey, fingerprint, iterative, cwd, {
|
|
95
|
+
countIteration: iterative,
|
|
96
|
+
allowAtLimit: iterative && isVerificationShell(command),
|
|
97
|
+
timeoutMs: shellTimeoutMs(payload),
|
|
98
|
+
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
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.3",
|
|
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",
|