harness-everything 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.11.4",
13
+ "version": "0.11.6",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.11.4",
4
+ "version": "0.11.6",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.11.6](https://github.com/dyphn1/Harness-everything/compare/v0.11.5...v0.11.6) (2026-09-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **workflow:** avoid workflow rewrite after probe registration ([b9f2e06](https://github.com/dyphn1/Harness-everything/commit/b9f2e065114d70e927b6f75e7c0f7899d9e8392f))
10
+ * **workflow:** correlate mutation probes by tool-use identity ([86cd1a1](https://github.com/dyphn1/Harness-everything/commit/86cd1a1c6eaef6518936160b0c35f94a3e652cae))
11
+ * **workflow:** defer shell mutation accounting to observer ([c543fd0](https://github.com/dyphn1/Harness-everything/commit/c543fd0c41e18f05c21235ce4ffcf28864200940))
12
+ * **workflow:** discard denied shell mutation probes ([7a8757c](https://github.com/dyphn1/Harness-everything/commit/7a8757cab821e524ccdd17e416e013bfbadc4f39))
13
+ * **workflow:** fingerprint observed workspace effects ([9d8e316](https://github.com/dyphn1/Harness-everything/commit/9d8e316eb97ec7bbcd0e46ceb51491af90e57e9f))
14
+ * **workflow:** fingerprint visible content independent of staging ([a1f370e](https://github.com/dyphn1/Harness-everything/commit/a1f370e64a97b04bee4d8df23e3ffdc7552377b8))
15
+ * **workflow:** isolate mutation probes per tool call ([70d6052](https://github.com/dyphn1/Harness-everything/commit/70d6052ef02fce1125da9a20a14a4ad65d09ba93))
16
+ * **workflow:** make mutation observation single-handler ordered ([ea166e1](https://github.com/dyphn1/Harness-everything/commit/ea166e1a94cdd1725cd7242feb5b0716a4dce8b8))
17
+ * **workflow:** normalize deleted paths in fingerprint ([e5019da](https://github.com/dyphn1/Harness-everything/commit/e5019daee870e806d77d92c741a9624cd6c9bfe0))
18
+ * **workflow:** observe shell effects before state persistence ([5271655](https://github.com/dyphn1/Harness-everything/commit/5271655627344eb71ffeb8dd36b007709862b9d0))
19
+ * **workflow:** observe shell workspace mutations ([c547b39](https://github.com/dyphn1/Harness-everything/commit/c547b396bf2e3829b83ed03abeca594b7391f542))
20
+ * **workflow:** remove parallel standalone mutation observer ([20027f6](https://github.com/dyphn1/Harness-everything/commit/20027f68f5b63719ea7a96a524f49c7103586644))
21
+ * **workflow:** reserve shell mutation probes ([e7273f6](https://github.com/dyphn1/Harness-everything/commit/e7273f66010d5b0c740b87b8dbc020c8de616fe7))
22
+ * **workflow:** settle mutation probes inside state persistence ([88feaf4](https://github.com/dyphn1/Harness-everything/commit/88feaf4ee3accbd8504b745fd4e79e548c31bd6d))
23
+
24
+ ## [0.11.5](https://github.com/dyphn1/Harness-everything/compare/v0.11.4...v0.11.5) (2026-09-18)
25
+
26
+ ### Bug Fixes
27
+
28
+ * **workflow:** lex shell separators safely ([df1a92c](https://github.com/dyphn1/Harness-everything/commit/df1a92ca6129ea96390e463ebcaef769604742e2))
29
+
5
30
  ## [0.11.4](https://github.com/dyphn1/Harness-everything/compare/v0.11.3...v0.11.4) (2026-09-18)
6
31
 
7
32
  ### Bug Fixes
@@ -123,6 +123,14 @@ try {
123
123
  'gh repo view octocat/hello-world --json name',
124
124
  'gh auth status',
125
125
  'cat README.md | grep fixture',
126
+ 'grep -n "fixture\\|missing" README.md | head -20',
127
+ "grep -n 'fixture|missing' README.md | head -20",
128
+ 'echo "fixture|missing" | grep fixture',
129
+ 'git log --grep="fix|feat" | head -5',
130
+ "grep 'fixture;missing' README.md | head -1",
131
+ 'grep fixture\\|missing README.md | head -1',
132
+ 'echo "2>&1"',
133
+ 'echo "safe; rm -rf src"',
126
134
  ]) {
127
135
  check(runGate('Bash', repo, { command }).status === 0, `trusted read-only chain remains allowed: ${command}`);
128
136
  }
@@ -136,6 +144,10 @@ try {
136
144
  'git worktree add "harness-chain-probe" -b harness-chain-probe; rm -rf src',
137
145
  'gh issue comment 1 --body hi',
138
146
  'gh issue close 1',
147
+ 'git status --short || rm -rf src',
148
+ 'grep "unterminated README.md | head -1',
149
+ "grep 'unterminated README.md | head -1",
150
+ 'echo safe | rm -rf src',
139
151
  ]) {
140
152
  check(runGate('Bash', repo, { command }).status === 2, `chained/redirected command is not read-only: ${command}`);
141
153
  }
@@ -35,6 +35,46 @@ function git(args) {
35
35
  }
36
36
  const route = prompt => node('harness-everything/scripts/kernel-router.js', { ...payload, prompt });
37
37
  const gate = (tool, input, cwd = linked) => node('hooks/scripts/workflow-gate.js', { ...payload, cwd, tool_name: tool, tool_input: input });
38
+ let toolUseSeq = 0;
39
+ function beginShell(command, cwd = repo) {
40
+ const toolUseId = 'toolu_fixture_' + (++toolUseSeq);
41
+ const input = { ...payload, cwd, tool_name: 'Bash', tool_use_id: toolUseId, tool_input: { command } };
42
+ return { command, cwd, toolUseId, result: node('hooks/scripts/workflow-gate.js', input) };
43
+ }
44
+ function persistShell(call, response = {}, hookEvent = 'PostToolUse') {
45
+ const input = {
46
+ ...payload,
47
+ cwd: call.cwd,
48
+ hook_event_name: hookEvent,
49
+ tool_name: 'Bash',
50
+ tool_use_id: call.toolUseId,
51
+ tool_input: { command: call.command },
52
+ };
53
+ if (hookEvent === 'PostToolUseFailure') {
54
+ input.error = response.error || 'Exit code 1\nfixture failure';
55
+ input.is_interrupt = Boolean(response.is_interrupt);
56
+ } else {
57
+ input.tool_response = response;
58
+ }
59
+ return node('hooks/scripts/state-persist.js', input);
60
+ }
61
+ function denyShell(call) {
62
+ return node('hooks/scripts/workflow-mutation-denied.js', {
63
+ ...payload,
64
+ cwd: call.cwd,
65
+ hook_event_name: 'PermissionDenied',
66
+ permission_mode: 'auto',
67
+ tool_name: 'Bash',
68
+ tool_use_id: call.toolUseId,
69
+ tool_input: { command: call.command },
70
+ reason: 'Blocked by classifier',
71
+ });
72
+ }
73
+ const probeDir = path.join(sessionDir, 'mutation-probes');
74
+ const probeFiles = () => {
75
+ try { return fs.readdirSync(probeDir).filter(name => /^[a-f0-9]{64}\.json$/.test(name)); }
76
+ catch (_) { return []; }
77
+ };
38
78
  const stop = extra => node('hooks/scripts/workflow-stop-gate.js', { ...payload, ...extra });
39
79
  const control = (...args) => node('hooks/scripts/workflow-disposition.js', null, [args[0], '--session-id', sessionId, ...args.slice(1)]);
40
80
  const stages = [
@@ -158,10 +198,157 @@ try {
158
198
  check(route('Fix this checkout bug with a regression test').status === 0 && read(file).strategy === 'iterative-single', 'fresh bounded fix returns to Tier 2 iterative lifecycle');
159
199
  const iterationLimit = read(file).workflowPlan.limits.maxIterations;
160
200
  check(Number.isInteger(iterationLimit) && iterationLimit > 0, 'iterative route exposes a numeric iteration budget');
161
- for (let i = 0; i < iterationLimit; i++) {
162
- check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `iterative mutation ${i + 1}/${iterationLimit} stays within budget`);
201
+
202
+ // #155/#157 regression lock: read-only shell composition, including quoted
203
+ // and escaped separator characters inside arguments, must never look like an
204
+ // iterative mutation or advance the mutation clock.
205
+ const beforeReadOnly = read(file);
206
+ const readOnlyIterations = beforeReadOnly.budget?.counters?.iterations || 0;
207
+ const readOnlyMutationAt = beforeReadOnly.lastMutationAt || 0;
208
+ const quotedReadOnly = [
209
+ 'git status --short; git log --oneline -1',
210
+ 'gh issue view 1 --repo octocat/hello-world --json number,title 2>&1',
211
+ 'grep -n "fixture\\|missing" README.md | head -20',
212
+ "grep -n 'fixture|missing' README.md | head -20",
213
+ 'echo "fixture|missing" | grep fixture',
214
+ "grep 'fixture;missing' README.md | head -1",
215
+ ];
216
+ for (let i = 0; i < iterationLimit + 2; i++) {
217
+ const command = quotedReadOnly[i % quotedReadOnly.length];
218
+ check(gate('Bash', { command }, repo).status === 0, 'quoted/escaped read-only shell command stays outside iteration budget: ' + command);
219
+ }
220
+ const afterReadOnly = read(file);
221
+ check((afterReadOnly.budget?.counters?.iterations || 0) === readOnlyIterations,
222
+ 'read-only shell composition does not consume iterative budget');
223
+ check((afterReadOnly.lastMutationAt || 0) === readOnlyMutationAt,
224
+ 'read-only shell composition does not advance lastMutationAt');
225
+
226
+ // #159: safety classification is not mutation evidence. Commands that are
227
+ // not proven read-only are admitted under an opaque workspace probe while
228
+ // capacity remains, then accounted only if the visible workspace changed.
229
+ const untrustedReads = [
230
+ 'git fetch origin --quiet 2>&1; git status; git log --oneline -1',
231
+ 'git remote -v',
232
+ 'gh run view 1 --json status',
233
+ 'node -e "console.log(42)"',
234
+ ];
235
+ for (const command of untrustedReads) {
236
+ const before = read(file);
237
+ const call = beginShell(command);
238
+ check(call.result.status === 0, 'untrusted read/query is admitted under observation: ' + command);
239
+ const probes = probeFiles();
240
+ check(probes.length === 1 && !fs.readFileSync(path.join(probeDir, probes[0]), 'utf8').includes(command),
241
+ 'mutation probe persists only opaque identity, not raw command text');
242
+ check(persistShell(call).status === 0, 'no-effect shell probe resolves through state-persist: ' + command);
243
+ check(probeFiles().length === 0, 'completed shell call removes its mutation probe');
244
+ const after = read(file);
245
+ check((after.budget?.counters?.iterations || 0) === (before.budget?.counters?.iterations || 0),
246
+ 'no-effect untrusted shell command consumes no iteration');
247
+ check((after.lastMutationAt || 0) === (before.lastMutationAt || 0),
248
+ 'no-effect untrusted shell command does not advance lastMutationAt');
249
+ }
250
+
251
+ const deniedCall = beginShell('node denied-by-auto-mode.js');
252
+ check(deniedCall.result.status === 0 && probeFiles().length === 1,
253
+ 'auto-mode candidate reserves mutation capacity before permission decision');
254
+ check(denyShell(deniedCall).status === 0 && probeFiles().length === 0,
255
+ 'PermissionDenied releases the exact tool-use mutation probe');
256
+ const parallelA = beginShell('node repeated-read-only-wrapper.js');
257
+ const parallelB = beginShell('node repeated-read-only-wrapper.js');
258
+ check(parallelA.result.status === 0 && parallelB.result.status === 0 && probeFiles().length === 2,
259
+ 'identical concurrent shell commands receive distinct tool-use probes');
260
+ check(denyShell(parallelA).status === 0 && persistShell(parallelB).status === 0 && probeFiles().length === 0,
261
+ 'parallel tool-use probes settle independently without reservation leakage');
262
+
263
+ const metadataFetch = 'git fetch . HEAD:refs/remotes/origin/harness-probe';
264
+ const beforeMetadataFetch = read(file);
265
+ const metadataFetchCall = beginShell(metadataFetch);
266
+ check(metadataFetchCall.result.status === 0,
267
+ 'metadata-only git fetch is admitted under observation');
268
+ git(['fetch', '.', 'HEAD:refs/remotes/origin/harness-probe']);
269
+ check(persistShell(metadataFetchCall).status === 0, 'real metadata-only git fetch probe resolves');
270
+ check((read(file).budget?.counters?.iterations || 0) === (beforeMetadataFetch.budget?.counters?.iterations || 0) &&
271
+ (read(file).lastMutationAt || 0) === (beforeMetadataFetch.lastMutationAt || 0),
272
+ 'real git fetch changes Git metadata without consuming a code iteration');
273
+
274
+ let observedIterations = read(file).budget?.counters?.iterations || 0;
275
+ const mutateTracked = 'node mutate-tracked.js';
276
+ const mutateTrackedCall = beginShell(mutateTracked);
277
+ check(mutateTrackedCall.result.status === 0, 'unknown tracked-file mutator is admitted under observation');
278
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed once\n');
279
+ check(persistShell(mutateTrackedCall).status === 0, 'tracked-file mutation is observed');
280
+ check(read(file).budget.counters.iterations === ++observedIterations, 'tracked-file mutation consumes exactly one iteration');
281
+ const firstObservedAt = read(file).lastMutationAt;
282
+
283
+ const mutateDirty = 'node mutate-dirty-again.js';
284
+ const mutateDirtyCall = beginShell(mutateDirty);
285
+ check(mutateDirtyCall.result.status === 0, 'unknown already-dirty mutator is admitted under observation');
286
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture changed twice\n');
287
+ check(persistShell(mutateDirtyCall).status === 0, 'already-dirty content change is observed');
288
+ check(read(file).budget.counters.iterations === ++observedIterations && read(file).lastMutationAt >= firstObservedAt,
289
+ 'already-dirty content change consumes one new iteration');
290
+
291
+ const createUntracked = 'node create-untracked.js';
292
+ const createUntrackedCall = beginShell(createUntracked);
293
+ check(createUntrackedCall.result.status === 0, 'unknown untracked-file mutator is admitted under observation');
294
+ fs.writeFileSync(path.join(repo, 'observed-untracked.txt'), 'one\n');
295
+ check(persistShell(createUntrackedCall).status === 0, 'untracked-file creation is observed');
296
+ check(read(file).budget.counters.iterations === ++observedIterations, 'untracked-file creation consumes exactly one iteration');
297
+
298
+ const failedMutation = 'node fail-after-write.js';
299
+ const failedMutationCall = beginShell(failedMutation);
300
+ check(failedMutationCall.result.status === 0, 'failing shell mutator is admitted under observation');
301
+ fs.writeFileSync(path.join(repo, 'observed-failed.txt'), 'written before failure\n');
302
+ check(persistShell(failedMutationCall, { error: 'Exit code 7\nfixture failure' }, 'PostToolUseFailure').status === 0,
303
+ 'failed shell command still reports workspace mutation through failure lifecycle');
304
+ check(read(file).budget.counters.iterations === ++observedIterations,
305
+ 'failed shell command that changed workspace still consumes one iteration');
306
+
307
+ const beforeStageOnly = read(file);
308
+ const stageOnly = 'git add README.md observed-untracked.txt';
309
+ const stageOnlyCall = beginShell(stageOnly);
310
+ check(stageOnlyCall.result.status === 0, 'staging-only git command is observed instead of pre-counted');
311
+ git(['add', 'README.md', 'observed-untracked.txt']);
312
+ check(persistShell(stageOnlyCall).status === 0, 'staging-only probe resolves');
313
+ check(read(file).budget.counters.iterations === beforeStageOnly.budget.counters.iterations,
314
+ 'git add does not double-count tracked or previously-untracked visible content');
315
+
316
+ const beforeCommitOnly = read(file);
317
+ const commitOnly = 'git commit -m observed-fixture';
318
+ const commitOnlyCall = beginShell(commitOnly);
319
+ check(commitOnlyCall.result.status === 0, 'pure commit is observed instead of pre-counted');
320
+ git(['-c', 'user.name=Harness Test', '-c', 'user.email=harness@example.invalid', 'commit', '-m', 'observed-fixture']);
321
+ check(persistShell(commitOnlyCall).status === 0, 'pure commit probe resolves');
322
+ check(read(file).budget.counters.iterations === beforeCommitOnly.budget.counters.iterations,
323
+ 'pure commit of already-accounted content does not consume another code iteration');
324
+
325
+ const deleteTracked = 'node delete-tracked.js';
326
+ const deleteTrackedCall = beginShell(deleteTracked);
327
+ check(deleteTrackedCall.result.status === 0, 'unknown tracked-file deletion is admitted under observation');
328
+ fs.unlinkSync(path.join(repo, 'observed-untracked.txt'));
329
+ check(persistShell(deleteTrackedCall).status === 0, 'tracked-file deletion is observed');
330
+ check(read(file).budget.counters.iterations === ++observedIterations,
331
+ 'tracked-file deletion consumes exactly one iteration');
332
+
333
+ const beforeStageDelete = read(file);
334
+ const stageDelete = 'git add -u observed-untracked.txt';
335
+ const stageDeleteCall = beginShell(stageDelete);
336
+ check(stageDeleteCall.result.status === 0, 'staging-only deletion is observed instead of pre-counted');
337
+ git(['add', '-u', 'observed-untracked.txt']);
338
+ check(persistShell(stageDeleteCall).status === 0, 'staging deletion probe resolves');
339
+ check(read(file).budget.counters.iterations === beforeStageDelete.budget.counters.iterations,
340
+ 'git add -u does not double-count an already-observed deletion');
341
+
342
+ observedIterations = read(file).budget.counters.iterations;
343
+ for (let i = observedIterations; i < iterationLimit; i++) {
344
+ check(gate('Write', { file_path: path.join(repo, `small-${i}.js`) }, repo).status === 0, `confirmed mutation ${i + 1}/${iterationLimit} stays within budget`);
163
345
  }
164
- check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2, 'iteration beyond maxIterations is blocked');
346
+ check(gate('Bash', { command: 'git status --short' }, repo).status === 0,
347
+ 'proven read-only diagnosis remains allowed at the iteration limit');
348
+ check(gate('Bash', { command: 'git remote -v' }, repo).status === 2,
349
+ 'untrusted shell command is blocked before execution once actual mutation budget is full');
350
+ check(gate('Write', { file_path: path.join(repo, 'small-overflow.js') }, repo).status === 2,
351
+ 'blocked exhausted workflow still rejects direct mutation');
165
352
  const iterativeExhausted = read(file);
166
353
  check(iterativeExhausted.state === 'blocked' && iterativeExhausted.budget?.state === 'budget-exhausted' &&
167
354
  iterativeExhausted.budget?.reasonCode === 'iteration-budget-exhausted', 'iterative exhaustion records blocked budget state');
@@ -175,11 +362,16 @@ try {
175
362
  check(stop().status === 2, 'direct/iterative route cannot complete unverified mutation');
176
363
  check(stop({ stop_hook_active: true }).status === 0 && read(file).state === 'blocked', 'iterative retry reports incomplete state');
177
364
  check(control('start').status === 0, 'blocked iterative route can resume without a Fable stage map');
178
- // Issue #153: a host is not guaranteed to report a numeric exit code at
179
- // all (state-persist.js's own isFailed logic already treats "no exit code
180
- // and no stderr" as success), so this must resolve rather than deadlock.
181
- node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { stdout: 'no code' } });
182
- check(stop().status === 0 && read(file).state === 'satisfied', 'verification without a host-reported numeric exit status still resolves completion');
365
+ // #153 + #159 ordering: when one shell command mutates and then verifies,
366
+ // observed mutation must land before state-persist records verification.
367
+ const mutateAndVerify = 'node mutate.js && npm test';
368
+ const mutateAndVerifyCall = beginShell(mutateAndVerify);
369
+ check(mutateAndVerifyCall.result.status === 0, 'combined mutation+verification command is admitted under observation');
370
+ fs.writeFileSync(path.join(repo, 'README.md'), 'fixture verified mutation\n');
371
+ check(persistShell(mutateAndVerifyCall, { stdout: 'tests passed' }).status === 0,
372
+ 'single state-persist handler records mutation before verification');
373
+ check(stop().status === 0 && read(file).state === 'satisfied',
374
+ 'verification without a host-reported numeric exit status resolves after same-command mutation');
183
375
  node('hooks/scripts/state-persist.js', { ...payload, tool_name: 'Bash', tool_input: { command: 'npm test' }, tool_response: { exitCode: 0, stdout: 'passed' } });
184
376
  check(stop().status === 0 && read(file).state === 'satisfied', 'observed iterative verification resolves completion');
185
377
  console.log(`PASS: workflow lifecycle (${passed} assertions)`);
package/hooks/hooks.json CHANGED
@@ -157,6 +157,19 @@
157
157
  "id": "harness:stop:skill-telemetry"
158
158
  }
159
159
  ],
160
+ "PermissionDenied": [
161
+ {
162
+ "matcher": "Bash|PowerShell",
163
+ "hooks": [
164
+ {
165
+ "type": "command",
166
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/workflow-mutation-denied.js\""
167
+ }
168
+ ],
169
+ "description": "Release reserved mutation capacity when Claude auto mode denies a shell tool call",
170
+ "id": "harness:permission-denied:mutation-probe"
171
+ }
172
+ ],
160
173
  "PostToolUse": [
161
174
  {
162
175
  "matcher": "Bash|PowerShell|mcp__.*",
@@ -188,7 +201,7 @@
188
201
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-persist.js\""
189
202
  }
190
203
  ],
191
- "description": "Save skill transition state",
204
+ "description": "Persist tool outcome, observed workspace mutation, and verification milestones",
192
205
  "id": "harness:post:state-persist"
193
206
  },
194
207
  {
@@ -259,6 +272,17 @@
259
272
  "description": "Track failed tool error signatures for Rule of 3",
260
273
  "id": "harness:post-failure:rule-of-3-tracker"
261
274
  },
275
+ {
276
+ "matcher": "Bash|PowerShell",
277
+ "hooks": [
278
+ {
279
+ "type": "command",
280
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-persist.js\""
281
+ }
282
+ ],
283
+ "description": "Persist failed shell outcome and any workspace mutation that occurred before failure",
284
+ "id": "harness:post-failure:state-persist"
285
+ },
262
286
  {
263
287
  "matcher": "Skill",
264
288
  "hooks": [
@@ -1,13 +1,89 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
  const path = require('path');
5
6
  const { spawnSync } = require('child_process');
6
7
 
7
- function git(cwd, args) {
8
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000 });
8
+ function gitRaw(cwd, args) {
9
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
9
10
  if (result.status !== 0) throw new Error('cannot verify Git worktree isolation');
10
- return result.stdout.trim();
11
+ return result.stdout;
12
+ }
13
+
14
+ function git(cwd, args) {
15
+ return gitRaw(cwd, args).trim();
16
+ }
17
+
18
+ function hashWorktreePath(root, relative) {
19
+ const target = path.join(root, relative);
20
+ let stat;
21
+ try { stat = fs.lstatSync(target); }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw error;
25
+ }
26
+ if (stat.isDirectory()) {
27
+ const result = spawnSync('git', ['-C', target, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
28
+ return { mode: '160000', oid: result.status === 0 ? result.stdout.trim() : 'directory' };
29
+ }
30
+ const mode = stat.isSymbolicLink() ? '120000' : ((stat.mode & 0o111) ? '100755' : '100644');
31
+ const result = spawnSync('git', ['hash-object', '--path=' + relative, '--', relative], {
32
+ cwd: root, encoding: 'utf8', windowsHide: true, timeout: 5000,
33
+ });
34
+ if (result.status !== 0) throw new Error('cannot fingerprint workspace content');
35
+ return { mode, oid: result.stdout.trim() };
36
+ }
37
+
38
+ function workspaceFingerprint(cwd) {
39
+ const root = canonical(git(cwd, ['rev-parse', '--show-toplevel']));
40
+ const entries = new Map();
41
+ for (const record of gitRaw(root, ['ls-files', '-s', '-z']).split('\0')) {
42
+ if (!record) continue;
43
+ const tab = record.indexOf('\t');
44
+ if (tab < 0) throw new Error('cannot fingerprint workspace index');
45
+ const meta = record.slice(0, tab).trim().split(/\s+/);
46
+ const relative = record.slice(tab + 1);
47
+ const mode = meta[0];
48
+ const oid = meta[1];
49
+ entries.set(relative, { relative, mode, oid });
50
+ }
51
+
52
+ // Substitute current worktree content for tracked paths that differ from the
53
+ // index. This keeps the fingerprint stable across git add / git commit when
54
+ // visible workspace content did not change.
55
+ for (const relative of gitRaw(root, ['diff-files', '--name-only', '-z']).split('\0').filter(Boolean)) {
56
+ const value = hashWorktreePath(root, relative);
57
+ if (value) entries.set(relative, { relative, mode: value.mode, oid: value.oid });
58
+ else entries.delete(relative);
59
+ }
60
+
61
+ for (const relative of gitRaw(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
62
+ const value = hashWorktreePath(root, relative);
63
+ entries.set(relative, { relative, mode: value.mode, oid: value.oid });
64
+ }
65
+
66
+ const digest = crypto.createHash('sha256');
67
+ digest.update('harness-workspace-fingerprint-v1\0');
68
+ for (const entry of [...entries.values()].sort((a, b) => a.relative.localeCompare(b.relative))) {
69
+ digest.update(entry.relative); digest.update('\0');
70
+ digest.update(entry.mode); digest.update('\0');
71
+ digest.update(entry.oid); digest.update('\0');
72
+ }
73
+ return digest.digest('hex');
74
+ }
75
+
76
+ function shellProbeKey(payload, cwd) {
77
+ const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
78
+ const tool = String(payload?.tool_name || payload?.tool || '');
79
+ const command = commandOf(payload);
80
+ return crypto.createHash('sha256')
81
+ .update('harness-shell-probe-v2\0')
82
+ .update(toolUseId || 'no-tool-use-id').update('\0')
83
+ .update(tool).update('\0')
84
+ .update(key(cwd)).update('\0')
85
+ .update(command)
86
+ .digest('hex');
11
87
  }
12
88
 
13
89
  function canonical(value) {
@@ -43,13 +119,12 @@ function linkedWorktree(cwd, boundRoot) {
43
119
  return worktrees.some(field => field.startsWith('worktree ') && key(field.slice(9)) === key(top)) ? top : null;
44
120
  }
45
121
 
46
- // A conservative read subset, not a general shell sandbox. Chaining (`;`, `&&`,
47
- // `||`, `|`) and the `2>&1`/`1>&2` fd-duplication redirect are permitted only
48
- // when every resulting segment independently matches the trusted read-only set
49
- // below; any other metacharacter (substitution, grouping, a lone `&`, a file
50
- // redirect, `<`) still forces mutation-or-unknown for the whole command.
51
- const SHELL_SEPARATOR_RE = /\s*(?:&&|\|\||;|\|)\s*/;
52
- const SAFE_FD_REDIRECT_RE = /[12]>&[12]/g;
122
+ // A conservative read subset, not a general shell sandbox. Top-level chaining
123
+ // (`;`, `&&`, `||`, `|`) and the `2>&1`/`1>&2` fd-duplication redirect
124
+ // are permitted only when every resulting segment independently matches the
125
+ // trusted read-only set below. The scanner is deliberately lexical rather than
126
+ // a raw regex split so quoted/escaped separator characters remain arguments.
127
+ // Unsupported shell syntax still fails closed.
53
128
  const DANGEROUS_FLAG_RE = /\s(?:--(?:output|ext-diff|textconv|exec|pre|pre-glob|pager|open|batch|filters)|-[xoO])(?:\b|=)|\s-(?:exec|execdir|delete|fprint|fprintf)\b/i;
54
129
  const READ_ONLY_PREFIX_RE = /^(?:git\s+(?:status|rev-parse|diff|log|show|ls-files|check-ignore)(?:\s|$)|git\s+branch\s+--show-current$|git\s+worktree\s+list(?:\s|$)|gh\s+(?:issue|pr)\s+(?:view|list)(?:\s|$)|gh\s+repo\s+view(?:\s|$)|gh\s+auth\s+status(?:\s|$)|(?:pwd|ls|dir|cat|type|head|tail|wc|stat|rg|grep|echo|Get-Location|Get-ChildItem|Get-Content|Select-String|Test-Path)(?:\s|$))/i;
55
130
 
@@ -57,20 +132,102 @@ function isReadOnlySegment(segment) {
57
132
  return READ_ONLY_PREFIX_RE.test(segment) && !DANGEROUS_FLAG_RE.test(segment);
58
133
  }
59
134
 
135
+ function splitShellSegments(text) {
136
+ const segments = [];
137
+ let current = '';
138
+ let quote = null;
139
+
140
+ const push = () => {
141
+ const segment = current.trim();
142
+ if (!segment) return false;
143
+ segments.push(segment);
144
+ current = '';
145
+ return true;
146
+ };
147
+
148
+ for (let i = 0; i < text.length; i++) {
149
+ const ch = text[i];
150
+
151
+ if (quote === "'") {
152
+ current += ch;
153
+ if (ch === "'") quote = null;
154
+ continue;
155
+ }
156
+
157
+ if (quote === '"') {
158
+ if (ch === '$' || ch === '`') return null;
159
+ current += ch;
160
+ if (ch === '"') {
161
+ quote = null;
162
+ } else if (ch === '\\') {
163
+ if (i + 1 >= text.length) return null;
164
+ current += text[++i];
165
+ }
166
+ continue;
167
+ }
168
+
169
+ if (ch === "'" || ch === '"') {
170
+ quote = ch;
171
+ current += ch;
172
+ continue;
173
+ }
174
+
175
+ if (ch === '\\') {
176
+ if (i + 1 >= text.length) return null;
177
+ current += ch + text[++i];
178
+ continue;
179
+ }
180
+
181
+ if (ch === '$' || ch === '`' || ch === '(' || ch === ')' || ch === '{' || ch === '}' ||
182
+ ch === '\r' || ch === '\n' || ch === '\x00') return null;
183
+
184
+ // Only treat fd duplication as syntax at a token boundary. Quoted text such
185
+ // as "2>&1" was handled above and remains literal argument content.
186
+ if ((ch === '1' || ch === '2') && text[i + 1] === '>' && text[i + 2] === '&' &&
187
+ (text[i + 3] === '1' || text[i + 3] === '2') &&
188
+ (i === 0 || /\s/.test(text[i - 1])) &&
189
+ (i + 4 === text.length || /[\s;|&]/.test(text[i + 4]))) {
190
+ current += ' ';
191
+ i += 3;
192
+ continue;
193
+ }
194
+
195
+ if (ch === '>' || ch === '<') return null;
196
+
197
+ if (ch === ';') {
198
+ if (!push()) return null;
199
+ continue;
200
+ }
201
+
202
+ if (ch === '|') {
203
+ if (!push()) return null;
204
+ if (text[i + 1] === '|') i++;
205
+ continue;
206
+ }
207
+
208
+ if (ch === '&') {
209
+ if (text[i + 1] !== '&' || !push()) return null;
210
+ i++;
211
+ continue;
212
+ }
213
+
214
+ current += ch;
215
+ }
216
+
217
+ if (quote || !push()) return null;
218
+ return segments;
219
+ }
220
+
60
221
  function classifyShell(command) {
61
222
  const text = String(command || '').trim();
62
- if (!text || /[`$(){}\r\n\x00]/.test(text)) return 'mutation-or-unknown';
63
- // Strip the safe `2>&1`/`1>&2` fd-duplication redirect first: it contains a
64
- // literal `&` that must not be mistaken for a lone backgrounding `&`.
65
- const withoutSafeRedirects = text.replace(SAFE_FD_REDIRECT_RE, ' ');
66
- if (/&/.test(withoutSafeRedirects.replace(/&&/g, ''))) return 'mutation-or-unknown';
67
- if (/[<>]/.test(withoutSafeRedirects)) return 'mutation-or-unknown';
68
- const segments = text.split(SHELL_SEPARATOR_RE).map(segment => segment.replace(SAFE_FD_REDIRECT_RE, '').trim());
223
+ if (!text) return 'untrusted';
224
+ const segments = splitShellSegments(text);
225
+ if (!segments) return 'untrusted';
69
226
  if (segments.length === 1) {
70
227
  if (/^git\s+worktree\s+add\s+/i.test(segments[0]) && !/\s--(?:force|checkout|detach)(?:\s|=|$)/i.test(segments[0])) return 'worktree-setup';
71
228
  return isReadOnlySegment(segments[0]) ? 'read-only' : 'mutation-or-unknown';
72
229
  }
73
- return segments.every(segment => segment && isReadOnlySegment(segment)) ? 'read-only' : 'mutation-or-unknown';
230
+ return segments.every(isReadOnlySegment) ? 'read-only' : 'mutation-or-unknown';
74
231
  }
75
232
 
76
233
  function inputOf(payload) { return payload?.tool_input ?? payload?.toolInput ?? payload?.input ?? {}; }
@@ -117,4 +274,4 @@ function assertShellScope(command, cwd, isolatedRoot) {
117
274
  }
118
275
  }
119
276
 
120
- module.exports = { canonical, key, within, linkedWorktree, classifyShell, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };
277
+ module.exports = { canonical, key, within, linkedWorktree, classifyShell, workspaceFingerprint, shellProbeKey, inputOf, cwdOf, commandOf, mutationPaths, assertTargets, assertShellScope };