harness-everything 0.6.0 → 0.6.1

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.6.0",
13
+ "version": "0.6.1",
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.6.0",
4
+ "version": "0.6.1",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.6.1](https://github.com/dyphn1/Harness-everything/compare/v0.6.0...v0.6.1) (2026-09-17)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **hooks:** harden atomic commit and destructive action gates ([#132](https://github.com/dyphn1/Harness-everything/issues/132)) ([e7dee3a](https://github.com/dyphn1/Harness-everything/commit/e7dee3aed9bc2c2922151743e573f62ecdd60039)), closes [#90](https://github.com/dyphn1/Harness-everything/issues/90) [#100](https://github.com/dyphn1/Harness-everything/issues/100)
10
+
5
11
  ## [0.6.0](https://github.com/dyphn1/Harness-everything/compare/v0.5.3...v0.6.0) (2026-09-17)
6
12
 
7
13
  ### Features
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('assert');
5
+ const { execFileSync, spawnSync } = require('child_process');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const ROOT = path.resolve(__dirname, '..');
11
+ const canonical = path.join(ROOT, 'hooks', 'scripts', 'atomic-commit-check.js');
12
+ const plugin = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'atomic-commit-check.js');
13
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-atomic-commit-'));
14
+ const repo = path.join(temp, 'repo');
15
+ const outside = path.join(temp, 'outside');
16
+ const stateHome = path.join(temp, 'state');
17
+
18
+ function git(args) {
19
+ return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
20
+ }
21
+
22
+ function invoke({ session, tool = 'Edit', target, patch }) {
23
+ const toolInput = tool === 'apply_patch'
24
+ ? { patch }
25
+ : { file_path: target };
26
+ const payload = {
27
+ session_id: session,
28
+ hook_event_name: 'PostToolUse',
29
+ tool_name: tool,
30
+ tool_input: toolInput,
31
+ cwd: repo,
32
+ };
33
+ return spawnSync(process.execPath, [canonical], {
34
+ cwd: repo,
35
+ input: JSON.stringify(payload),
36
+ encoding: 'utf8',
37
+ env: { ...process.env, HARNESS_STATE_HOME: stateHome },
38
+ });
39
+ }
40
+
41
+ function sixInvocations(args) {
42
+ const results = [];
43
+ for (let i = 0; i < 6; i++) results.push(invoke(args));
44
+ return results;
45
+ }
46
+
47
+ try {
48
+ fs.mkdirSync(repo, { recursive: true });
49
+ fs.mkdirSync(outside, { recursive: true });
50
+ git(['init']);
51
+ git(['config', 'user.email', 'harness@example.invalid']);
52
+ git(['config', 'user.name', 'Harness Test']);
53
+ fs.writeFileSync(path.join(repo, 'tracked.txt'), 'base\n', 'utf8');
54
+ git(['add', 'tracked.txt']);
55
+ git(['commit', '-m', 'initial']);
56
+
57
+ assert.strictEqual(
58
+ fs.readFileSync(canonical, 'utf8'),
59
+ fs.readFileSync(plugin, 'utf8'),
60
+ 'canonical and packaged atomic-commit hooks must remain byte-identical',
61
+ );
62
+
63
+ // Negative control for #90: keep the repository dirty with a pre-existing,
64
+ // unrelated file. Six host edits outside the workspace must still never
65
+ // increment the repository edit counter or emit a commit nudge.
66
+ fs.writeFileSync(path.join(repo, 'pre-existing-unrelated.txt'), 'unrelated\n', 'utf8');
67
+ const outsideTarget = path.join(outside, 'scratch.txt');
68
+ const outsideResults = sixInvocations({ session: 'outside-edit', target: outsideTarget });
69
+ assert.ok(outsideResults.every(result => result.status === 0), 'outside-workspace Edit calls must never nudge even when the repo is already dirty');
70
+ assert.ok(outsideResults.every(result => !String(result.stderr || '').includes('Atomic Commit Check')), 'outside-workspace Edit calls must stay silent');
71
+
72
+ const outsidePatchResults = sixInvocations({
73
+ session: 'outside-patch',
74
+ tool: 'apply_patch',
75
+ patch: `*** Begin Patch\n*** Update File: ${outsideTarget}\n@@\n-old\n+new\n*** End Patch`,
76
+ });
77
+ assert.ok(outsidePatchResults.every(result => result.status === 0), 'outside-workspace apply_patch headers must not increment the count');
78
+ fs.rmSync(path.join(repo, 'pre-existing-unrelated.txt'));
79
+
80
+ // A clean worktree is never a valid reason to request a commit. The sixth
81
+ // in-workspace event therefore stays non-blocking and resets the stale count.
82
+ const cleanResults = sixInvocations({ session: 'clean-tree', target: path.join(repo, 'tracked.txt') });
83
+ assert.ok(cleanResults.every(result => result.status === 0), 'clean worktree must not nudge at the threshold');
84
+ assert.ok(cleanResults.every(result => !String(result.stderr || '').includes('Atomic Commit Check')), 'clean worktree must not print a commit reminder');
85
+
86
+ // Real in-workspace edits with Git-visible changes still preserve the nudge.
87
+ fs.writeFileSync(path.join(repo, 'tracked.txt'), 'changed\n', 'utf8');
88
+ const dirtyResults = sixInvocations({ session: 'dirty-tree', target: path.join(repo, 'tracked.txt') });
89
+ assert.ok(dirtyResults.slice(0, 5).every(result => result.status === 0), 'first five in-workspace edits remain non-blocking');
90
+ assert.strictEqual(dirtyResults[5].status, 2, 'sixth in-workspace edit with uncommitted changes nudges');
91
+ assert.match(String(dirtyResults[5].stderr || ''), /6 edits since the last commit/, 'nudge wording is tool-neutral');
92
+ assert.doesNotMatch(String(dirtyResults[5].stderr || ''), /apply_patch calls/, 'nudge no longer claims all edits are apply_patch calls');
93
+
94
+ // apply_patch paths inside the repository count too.
95
+ const patchResults = sixInvocations({
96
+ session: 'inside-patch',
97
+ tool: 'apply_patch',
98
+ patch: '*** Begin Patch\n*** Update File: tracked.txt\n@@\n-old\n+new\n*** End Patch',
99
+ });
100
+ assert.strictEqual(patchResults[5].status, 2, 'in-workspace apply_patch header participates in the threshold');
101
+
102
+ // Moving HEAD resets the counter for the same session.
103
+ git(['add', 'tracked.txt']);
104
+ git(['commit', '-m', 'save change']);
105
+ const afterCommit = invoke({ session: 'dirty-tree', target: path.join(repo, 'tracked.txt') });
106
+ assert.strictEqual(afterCommit.status, 0, 'counter resets after a commit moves HEAD');
107
+
108
+ console.log('PASS: atomic commit check scopes edits to the workspace and suppresses clean-tree false nudges');
109
+ } finally {
110
+ fs.rmSync(temp, { recursive: true, force: true });
111
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const ROOT = path.resolve(__dirname, '..');
10
+ const canonicalScript = path.join(ROOT, 'hooks', 'scripts', 'action-gate.js');
11
+ const pluginScript = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'action-gate.js');
12
+ const canonicalRules = path.join(ROOT, 'hooks', 'scripts', 'action-gate-rules.json');
13
+ const pluginRules = path.join(ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'action-gate-rules.json');
14
+ const actionGate = require(canonicalScript);
15
+
16
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-action-gate-hardening-'));
17
+ const sessionDir = path.join(temp, 'session');
18
+ fs.mkdirSync(path.join(sessionDir, 'scratch'), { recursive: true });
19
+
20
+ function payload(command, tool = 'Bash', cwd = ROOT) {
21
+ return {
22
+ session_id: 'classifier-hardening',
23
+ hook_event_name: 'PreToolUse',
24
+ tool_name: tool,
25
+ tool_input: { command },
26
+ cwd,
27
+ };
28
+ }
29
+
30
+ function matched(table, command, expected, tool = 'Bash', cwd = ROOT) {
31
+ const rule = actionGate.classify(payload(command, tool, cwd), table.rules, sessionDir);
32
+ assert.ok(rule, `${command} should match ${expected}`);
33
+ assert.strictEqual(rule.id, expected, `${command} should match ${expected}, got ${rule && rule.id}`);
34
+ }
35
+
36
+ function allowed(table, command, tool = 'Bash', cwd = ROOT) {
37
+ const rule = actionGate.classify(payload(command, tool, cwd), table.rules, sessionDir);
38
+ assert.strictEqual(rule, null, `${command} should not be classified as destructive`);
39
+ }
40
+
41
+ try {
42
+ assert.strictEqual(fs.readFileSync(canonicalScript, 'utf8'), fs.readFileSync(pluginScript, 'utf8'), 'canonical and packaged action-gate scripts must remain byte-identical');
43
+ assert.strictEqual(fs.readFileSync(canonicalRules, 'utf8'), fs.readFileSync(pluginRules, 'utf8'), 'canonical and packaged action-gate rule tables must remain byte-identical');
44
+
45
+ const table = actionGate.loadRuleTable(canonicalRules);
46
+ assert.strictEqual(table.degraded, false, 'production rule table must load normally');
47
+
48
+ const builtinById = new Map(actionGate.BUILTIN_RULES.map(rule => [rule.id, rule]));
49
+ for (const rule of table.rules) {
50
+ const fallback = builtinById.get(rule.id);
51
+ assert.ok(fallback, `built-in fallback must contain ${rule.id}`);
52
+ assert.deepStrictEqual(
53
+ { tools: fallback.tools, pattern: fallback.pattern, flags: fallback.flags, reason: fallback.reason, scope: fallback.scope },
54
+ { tools: rule.tools, pattern: rule.pattern, flags: rule.flags, reason: rule.reason, scope: rule.scope },
55
+ `built-in fallback must stay aligned with configured rule ${rule.id}`,
56
+ );
57
+ }
58
+
59
+ // Confirmed fail-open variants from #100.
60
+ matched(table, 'git -C . push --force origin main', 'git-force-push');
61
+ matched(table, 'git push origin +main', 'git-force-push');
62
+ matched(table, 'git push -uf origin main', 'git-force-push');
63
+ matched(table, 'git reset -q --hard HEAD~1', 'git-reset-hard');
64
+ matched(table, 'git -c key=value reset --hard', 'git-reset-hard');
65
+ matched(table, 'sudo rm -rf /opt/app', 'recursive-delete');
66
+ matched(table, 'bash -c "rm -rf C:/important"', 'recursive-delete');
67
+ matched(table, 'ri -Recurse -Force C:\\repo\\x', 'powershell-recursive-force-delete', 'PowerShell');
68
+ matched(table, 'rm -r -fo C:\\repo\\x', 'powershell-recursive-force-delete', 'PowerShell');
69
+
70
+ // Quoted/descriptive text is not an executable destructive command.
71
+ allowed(table, 'node -e "console.log(\'Remove-Item -Recurse -Force C:\\\\repo\\\\x\')"', 'Bash');
72
+ allowed(table, 'echo "never DROP TABLE users"');
73
+ allowed(table, 'grep -rn "npm publish" docs/');
74
+ allowed(table, 'git push origin main');
75
+
76
+ // Scratch cleanup keeps working for direct commands and PowerShell aliases.
77
+ const bashScratch = process.platform === 'win32' ? '/tmp/harness-action-gate-safe' : path.join(os.tmpdir(), 'harness-action-gate-safe');
78
+ allowed(table, `rm -rf "${bashScratch}"`, 'Bash');
79
+ const psScratch = path.join(sessionDir, 'scratch', 'build');
80
+ allowed(table, `rm -r -fo "${psScratch}"`, 'PowerShell');
81
+ allowed(table, `ri -Recurse -Force "${psScratch}"`, 'PowerShell');
82
+
83
+ // An unresolved variable remains conservative rather than creating a new
84
+ // fail-open path: the gate cannot prove that the target is scratch space.
85
+ matched(table, 'rm -rf "$S"', 'recursive-delete');
86
+
87
+ console.log('PASS: destructive-command classifier covers confirmed bypasses while suppressing quoted-text false positives');
88
+ } finally {
89
+ fs.rmSync(temp, { recursive: true, force: true });
90
+ }
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "id": "git-force-push",
6
6
  "tools": ["Bash", "PowerShell"],
7
- "pattern": "\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))",
7
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))",
8
8
  "flags": "i",
9
9
  "reason": "Force-pushing can rewrite remote history.",
10
10
  "scope": "always"
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "id": "git-reset-hard",
14
14
  "tools": ["Bash", "PowerShell"],
15
- "pattern": "\\bgit\\s+reset\\s+--hard\\b",
15
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b",
16
16
  "flags": "i",
17
17
  "reason": "git reset --hard can discard local work.",
18
18
  "scope": "always"
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "id": "git-clean-force-delete",
22
22
  "tools": ["Bash", "PowerShell"],
23
- "pattern": "\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))",
23
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))",
24
24
  "flags": "i",
25
25
  "reason": "git clean with force and directory deletion can remove untracked work.",
26
26
  "scope": "always"
@@ -28,15 +28,15 @@
28
28
  {
29
29
  "id": "recursive-delete",
30
30
  "tools": ["Bash"],
31
- "pattern": "(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
31
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\\"']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
32
32
  "flags": "i",
33
33
  "reason": "Recursive forced deletion outside approved scratch space is destructive.",
34
34
  "scope": "outside-scratch"
35
35
  },
36
36
  {
37
37
  "id": "powershell-recursive-force-delete",
38
- "tools": ["Bash", "PowerShell"],
39
- "pattern": "\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
38
+ "tools": ["PowerShell"],
39
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
40
40
  "flags": "i",
41
41
  "reason": "PowerShell recursive forced deletion outside approved scratch space is destructive.",
42
42
  "scope": "outside-scratch"
@@ -44,7 +44,7 @@
44
44
  {
45
45
  "id": "sql-destructive-ddl",
46
46
  "tools": ["Bash", "PowerShell"],
47
- "pattern": "\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
47
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
48
48
  "flags": "i",
49
49
  "reason": "Destructive SQL DDL can irreversibly remove production data or schema state.",
50
50
  "scope": "always"
@@ -52,7 +52,7 @@
52
52
  {
53
53
  "id": "package-publish",
54
54
  "tools": ["Bash", "PowerShell"],
55
- "pattern": "\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
55
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
56
56
  "flags": "i",
57
57
  "reason": "Publishing creates an external package/release side effect.",
58
58
  "scope": "always"
@@ -60,7 +60,7 @@
60
60
  {
61
61
  "id": "github-release-create",
62
62
  "tools": ["Bash", "PowerShell"],
63
- "pattern": "\\bgh\\s+release\\s+create\\b",
63
+ "pattern": "(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b",
64
64
  "flags": "i",
65
65
  "reason": "Creating a GitHub release is an external irreversible publication action.",
66
66
  "scope": "always"
@@ -68,7 +68,7 @@
68
68
  {
69
69
  "id": "production-deploy",
70
70
  "tools": ["Bash", "PowerShell"],
71
- "pattern": "\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
71
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
72
72
  "flags": "i",
73
73
  "reason": "Deployment changes an external environment and requires explicit approval.",
74
74
  "scope": "always"
@@ -20,15 +20,15 @@ const ALWAYS_ASK_POLICY = 'always-ask';
20
20
  // Fail-safe defaults are used only when the configured table is missing or
21
21
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
22
22
  const BUILTIN_RULES = [
23
- ['git-force-push', ['Bash', 'PowerShell'], '\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
- ['git-reset-hard', ['Bash', 'PowerShell'], '\\bgit\\s+reset\\s+--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
- ['git-clean-force-delete', ['Bash', 'PowerShell'], '\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
- ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
- ['powershell-recursive-force-delete', ['Bash', 'PowerShell'], '\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
- ['sql-destructive-ddl', ['Bash', 'PowerShell'], '\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
- ['package-publish', ['Bash', 'PowerShell'], '\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
- ['github-release-create', ['Bash', 'PowerShell'], '\\bgh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
- ['production-deploy', ['Bash', 'PowerShell'], '\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
23
+ ['git-force-push', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
+ ['git-reset-hard', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
+ ['git-clean-force-delete', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
+ ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\"\']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
+ ['powershell-recursive-force-delete', ['PowerShell'], '(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
+ ['sql-destructive-ddl', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
+ ['package-publish', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
+ ['github-release-create', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
+ ['production-deploy', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
32
32
  ].map(([id, tools, pattern, flags, reason, scope]) => ({ id, tools, pattern, flags, reason, scope }));
33
33
 
34
34
  function hashExact(value) {
@@ -153,9 +153,15 @@ function deleteTargets(command, ruleId) {
153
153
  const tails = [];
154
154
  let regex = null;
155
155
  if (ruleId === 'recursive-delete') {
156
- regex = /(?:^|[;&|\n]\s*)rm\s+([^;&|\n]+)/ig;
156
+ regex = /(?:^|[;&|\n]\s*)(?:sudo(?:\s+-\S+)*\s+)?rm\s+([^;&|\n]+)/ig;
157
+ const wrapper = /(?:^|[;&|\n]\s*)(?:bash|sh)\s+(?:-\S+\s+)*-c\s+(["'])([\s\S]*?)\1/ig;
158
+ let wrapped;
159
+ while ((wrapped = wrapper.exec(text)) !== null) {
160
+ tails.push(...deleteTargets(wrapped[2], ruleId));
161
+ if (wrapped[0].length === 0) wrapper.lastIndex++;
162
+ }
157
163
  } else if (ruleId === 'powershell-recursive-force-delete') {
158
- regex = /\bRemove-Item\b([^;|\n]+)/ig;
164
+ regex = /(?:^|[;|\n]\s*)(?:Remove-Item|ri|rm)\b([^;|\n]+)/ig;
159
165
  }
160
166
  if (!regex) return [];
161
167
 
@@ -190,6 +196,10 @@ function isWithin(target, root) {
190
196
 
191
197
  function scratchRoots(payload, sessionDir) {
192
198
  const roots = [os.tmpdir(), path.join(sessionDir, 'scratch')];
199
+ // Git Bash/MSYS exposes the Windows temp area as /tmp (and sometimes
200
+ // /var/tmp). Treat those aliases as scratch on Windows instead of gating a
201
+ // harmless temp cleanup merely because the host path syntax differs.
202
+ if (process.platform === 'win32') roots.push('/tmp', '/var/tmp');
193
203
  const declared = payload && (payload.scratchpad_path || payload.scratchpadPath || (payload.harness && payload.harness.scratchpadPath));
194
204
  if (declared) roots.push(declared);
195
205
  if (process.env.HARNESS_SCRATCH_DIR) roots.push(process.env.HARNESS_SCRATCH_DIR);
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Atomic Commit Check (PostToolUse: apply_patch)
4
- * PostToolUse can't block - the edit/write already happened. So this counts
5
- * apply_patch calls since the last commit and, once the count crosses a
6
- * threshold, feeds a strong reminder back to Claude (exit 2) to commit the
7
- * completed chunk before piling on more changes. Resets whenever HEAD moves.
3
+ * Atomic Commit Check (PostToolUse: Edit/Write/apply_patch)
4
+ * PostToolUse can't block - the edit/write already happened. Count only edits
5
+ * whose targets resolve inside the current workspace. Once the count crosses
6
+ * the threshold, emit a strong reminder only when the Git worktree actually
7
+ * has uncommitted changes. Resets whenever HEAD moves.
8
8
  * Fails open on any error - this is a nudge, never a hard stop.
9
9
  */
10
10
  const { execSync } = require('child_process');
@@ -15,6 +15,90 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
15
15
  const THRESHOLD = 6;
16
16
  const RENUDGE_EVERY = 3;
17
17
 
18
+ function toolNameOf(payload) {
19
+ return String((payload && (payload.tool_name || payload.toolName)) || '');
20
+ }
21
+
22
+ function toolInputOf(payload) {
23
+ return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
24
+ }
25
+
26
+ function pathApiFor(value) {
27
+ return /^[A-Za-z]:[\\/]/.test(String(value || '')) ? path.win32 : path;
28
+ }
29
+
30
+ function canonicalPath(value) {
31
+ const text = String(value || '');
32
+ if (!text) return null;
33
+ const api = pathApiFor(text);
34
+ const resolved = api.resolve(text);
35
+
36
+ // realpath() is necessary on macOS where /var is a symlink to /private/var.
37
+ // New Write targets may not exist yet, so canonicalize the nearest existing
38
+ // ancestor and append the missing path segments without requiring creation.
39
+ const missing = [];
40
+ let cursor = resolved;
41
+ while (cursor && !fs.existsSync(cursor)) {
42
+ const parent = api.dirname(cursor);
43
+ if (!parent || parent === cursor) break;
44
+ missing.unshift(api.basename(cursor));
45
+ cursor = parent;
46
+ }
47
+ if (!cursor || !fs.existsSync(cursor)) return resolved;
48
+
49
+ let real = fs.realpathSync.native ? fs.realpathSync.native(cursor) : fs.realpathSync(cursor);
50
+ for (const segment of missing) real = api.join(real, segment);
51
+ return api.normalize(real);
52
+ }
53
+
54
+ function isWithin(target, root) {
55
+ if (!target || !root) return false;
56
+ const api = pathApiFor(target) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
57
+ const resolvedTarget = canonicalPath(target);
58
+ const resolvedRoot = canonicalPath(root);
59
+ const relative = api.relative(resolvedRoot, resolvedTarget);
60
+ if (!relative) return true;
61
+ return relative !== '..' && !relative.startsWith(`..${api.sep}`) && !api.isAbsolute(relative);
62
+ }
63
+
64
+ function absoluteTarget(target, root) {
65
+ const text = String(target || '').trim();
66
+ if (!text) return null;
67
+ const api = pathApiFor(text) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
68
+ return api.isAbsolute(text) ? api.normalize(text) : api.resolve(root, text);
69
+ }
70
+
71
+ function patchTargets(patchText) {
72
+ const targets = [];
73
+ const regex = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
74
+ let match;
75
+ while ((match = regex.exec(String(patchText || ''))) !== null) targets.push(match[1]);
76
+ return targets;
77
+ }
78
+
79
+ function editTargets(payload) {
80
+ const tool = toolNameOf(payload);
81
+ const input = toolInputOf(payload);
82
+ if (tool === 'Edit' || tool === 'Write') {
83
+ const target = input.file_path || input.filePath || input.path;
84
+ return target ? [String(target)] : [];
85
+ }
86
+ if (tool === 'apply_patch') {
87
+ return patchTargets(input.patch || input.command || input.content || '');
88
+ }
89
+ return [];
90
+ }
91
+
92
+ function touchesWorkspace(payload, root) {
93
+ const targets = editTargets(payload);
94
+ if (targets.length === 0) return false;
95
+ return targets.some(target => isWithin(absoluteTarget(target, root), root));
96
+ }
97
+
98
+ function writeState(stateFile, state) {
99
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
100
+ }
101
+
18
102
  function main(payload) {
19
103
  try {
20
104
  const root = getWorkspaceRoot(payload);
@@ -33,17 +117,32 @@ function main(payload) {
33
117
  state.editCount = 0;
34
118
  }
35
119
 
120
+ // Host scratchpads, memory stores, temp files, and any other target outside
121
+ // the repository must not contribute to a repository commit reminder.
122
+ if (!touchesWorkspace(payload, root)) {
123
+ writeState(stateFile, state);
124
+ process.exit(0);
125
+ }
126
+
36
127
  state.editCount += 1;
37
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
128
+ writeState(stateFile, state);
38
129
 
39
130
  const overThreshold = state.editCount >= THRESHOLD;
40
131
  const onNudgeBeat = overThreshold && (state.editCount - THRESHOLD) % RENUDGE_EVERY === 0;
41
132
 
42
133
  if (onNudgeBeat) {
43
134
  const statusRaw = execSync('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
44
- const changedFiles = statusRaw ? statusRaw.split('\n').length : 0;
45
- console.error(`[Atomic Commit Check] ${state.editCount} apply_patch calls since the last commit (${changedFiles} files currently changed).`);
46
- console.error(`If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.`);
135
+ if (!statusRaw) {
136
+ // Calls that produced no Git-visible change (or work already reverted)
137
+ // must not leave a stale counter that immediately re-nudges later.
138
+ state.editCount = 0;
139
+ writeState(stateFile, state);
140
+ process.exit(0);
141
+ }
142
+
143
+ const changedFiles = statusRaw.split('\n').length;
144
+ console.error(`[Atomic Commit Check] ${state.editCount} edits since the last commit (${changedFiles} files currently changed).`);
145
+ console.error('If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.');
47
146
  process.exit(2);
48
147
  }
49
148
 
@@ -66,3 +165,12 @@ process.stdin.on('error', () => {
66
165
  clearTimeout(timeout);
67
166
  main(null);
68
167
  });
168
+
169
+ module.exports = {
170
+ absoluteTarget,
171
+ canonicalPath,
172
+ editTargets,
173
+ isWithin,
174
+ patchTargets,
175
+ touchesWorkspace,
176
+ };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.6.0",
4
+ "version": "0.6.1",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "id": "git-force-push",
6
6
  "tools": ["Bash", "PowerShell"],
7
- "pattern": "\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))",
7
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))",
8
8
  "flags": "i",
9
9
  "reason": "Force-pushing can rewrite remote history.",
10
10
  "scope": "always"
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "id": "git-reset-hard",
14
14
  "tools": ["Bash", "PowerShell"],
15
- "pattern": "\\bgit\\s+reset\\s+--hard\\b",
15
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b",
16
16
  "flags": "i",
17
17
  "reason": "git reset --hard can discard local work.",
18
18
  "scope": "always"
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "id": "git-clean-force-delete",
22
22
  "tools": ["Bash", "PowerShell"],
23
- "pattern": "\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))",
23
+ "pattern": "(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))",
24
24
  "flags": "i",
25
25
  "reason": "git clean with force and directory deletion can remove untracked work.",
26
26
  "scope": "always"
@@ -28,15 +28,15 @@
28
28
  {
29
29
  "id": "recursive-delete",
30
30
  "tools": ["Bash"],
31
- "pattern": "(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
31
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\\"']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))",
32
32
  "flags": "i",
33
33
  "reason": "Recursive forced deletion outside approved scratch space is destructive.",
34
34
  "scope": "outside-scratch"
35
35
  },
36
36
  {
37
37
  "id": "powershell-recursive-force-delete",
38
- "tools": ["Bash", "PowerShell"],
39
- "pattern": "\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
38
+ "tools": ["PowerShell"],
39
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)",
40
40
  "flags": "i",
41
41
  "reason": "PowerShell recursive forced deletion outside approved scratch space is destructive.",
42
42
  "scope": "outside-scratch"
@@ -44,7 +44,7 @@
44
44
  {
45
45
  "id": "sql-destructive-ddl",
46
46
  "tools": ["Bash", "PowerShell"],
47
- "pattern": "\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
47
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b",
48
48
  "flags": "i",
49
49
  "reason": "Destructive SQL DDL can irreversibly remove production data or schema state.",
50
50
  "scope": "always"
@@ -52,7 +52,7 @@
52
52
  {
53
53
  "id": "package-publish",
54
54
  "tools": ["Bash", "PowerShell"],
55
- "pattern": "\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
55
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b",
56
56
  "flags": "i",
57
57
  "reason": "Publishing creates an external package/release side effect.",
58
58
  "scope": "always"
@@ -60,7 +60,7 @@
60
60
  {
61
61
  "id": "github-release-create",
62
62
  "tools": ["Bash", "PowerShell"],
63
- "pattern": "\\bgh\\s+release\\s+create\\b",
63
+ "pattern": "(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b",
64
64
  "flags": "i",
65
65
  "reason": "Creating a GitHub release is an external irreversible publication action.",
66
66
  "scope": "always"
@@ -68,7 +68,7 @@
68
68
  {
69
69
  "id": "production-deploy",
70
70
  "tools": ["Bash", "PowerShell"],
71
- "pattern": "\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
71
+ "pattern": "(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b",
72
72
  "flags": "i",
73
73
  "reason": "Deployment changes an external environment and requires explicit approval.",
74
74
  "scope": "always"
@@ -20,15 +20,15 @@ const ALWAYS_ASK_POLICY = 'always-ask';
20
20
  // Fail-safe defaults are used only when the configured table is missing or
21
21
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
22
22
  const BUILTIN_RULES = [
23
- ['git-force-push', ['Bash', 'PowerShell'], '\\bgit\\s+push\\b[\\s\\S]*(?:--force(?:-with-lease)?|(?:^|\\s)-f(?:\\s|$))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
- ['git-reset-hard', ['Bash', 'PowerShell'], '\\bgit\\s+reset\\s+--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
- ['git-clean-force-delete', ['Bash', 'PowerShell'], '\\bgit\\s+clean\\b(?=[^\\r\\n]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
- ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
- ['powershell-recursive-force-delete', ['Bash', 'PowerShell'], '\\bRemove-Item\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
- ['sql-destructive-ddl', ['Bash', 'PowerShell'], '\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
- ['package-publish', ['Bash', 'PowerShell'], '\\b(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
- ['github-release-create', ['Bash', 'PowerShell'], '\\bgh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
- ['production-deploy', ['Bash', 'PowerShell'], '\\b(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
23
+ ['git-force-push', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*push\\b(?=[^\\r\\n;&|]*(?:--force(?:-with-lease)?\\b|-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[^\\s;&|]+))', 'i', 'Force-pushing can rewrite remote history.', 'always'],
24
+ ['git-reset-hard', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*reset\\b[^\\r\\n;&|]*--hard\\b', 'i', 'git reset --hard can discard local work.', 'always'],
25
+ ['git-clean-force-delete', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)git\\s+(?:(?:-C|-c)\\s+\\S+\\s+|-\\S+\\s+)*clean\\b(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*f|f\\b))(?=[^\\r\\n;&|]*\\s-(?:[^\\s]*d|d\\b))', 'i', 'git clean with force and directory deletion can remove untracked work.', 'always'],
26
+ ['recursive-delete', ['Bash'], '(?:^|[;&|\\n]\\s*)(?:(?:bash|sh)\\s+(?:-\\S+\\s+)*-c\\s+[\\"\']\\s*)?(?:sudo(?:\\s+-\\S+)*\\s+)?rm\\s+(?=[^\\r\\n;&|]*-(?:[^\\s]*r|r\\b))(?=[^\\r\\n;&|]*-(?:[^\\s]*f|f\\b))', 'i', 'Recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
27
+ ['powershell-recursive-force-delete', ['PowerShell'], '(?:^|[;&|\\n]\\s*)(?:Remove-Item|ri|rm)\\b(?=[^\\r\\n;|]*-(?:Recurse|r)\\b)(?=[^\\r\\n;|]*-(?:Force|fo)\\b)', 'i', 'PowerShell recursive forced deletion outside approved scratch space is destructive.', 'outside-scratch'],
28
+ ['sql-destructive-ddl', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:(?:psql|mysql|sqlcmd|sqlite3)\\b[^\\r\\n;&|]*)?\\b(?:DROP\\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE(?:\\s+TABLE)?)\\b', 'i', 'Destructive SQL DDL can irreversibly remove production data or schema state.', 'always'],
29
+ ['package-publish', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:sudo\\s+)?(?:npm|pnpm|yarn|cargo|twine)\\s+publish\\b', 'i', 'Publishing creates an external package/release side effect.', 'always'],
30
+ ['github-release-create', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)gh\\s+release\\s+create\\b', 'i', 'Creating a GitHub release is an external irreversible publication action.', 'always'],
31
+ ['production-deploy', ['Bash', 'PowerShell'], '(?:^|[;&|\\n]\\s*)(?:kubectl\\s+apply|helm\\s+(?:upgrade|install)|terraform\\s+apply|vercel\\s+deploy|fly\\s+deploy|gcloud\\s+run\\s+deploy|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy|deploy\\s+to\\s+(?:prod|production|live))\\b', 'i', 'Deployment changes an external environment and requires explicit approval.', 'always'],
32
32
  ].map(([id, tools, pattern, flags, reason, scope]) => ({ id, tools, pattern, flags, reason, scope }));
33
33
 
34
34
  function hashExact(value) {
@@ -153,9 +153,15 @@ function deleteTargets(command, ruleId) {
153
153
  const tails = [];
154
154
  let regex = null;
155
155
  if (ruleId === 'recursive-delete') {
156
- regex = /(?:^|[;&|\n]\s*)rm\s+([^;&|\n]+)/ig;
156
+ regex = /(?:^|[;&|\n]\s*)(?:sudo(?:\s+-\S+)*\s+)?rm\s+([^;&|\n]+)/ig;
157
+ const wrapper = /(?:^|[;&|\n]\s*)(?:bash|sh)\s+(?:-\S+\s+)*-c\s+(["'])([\s\S]*?)\1/ig;
158
+ let wrapped;
159
+ while ((wrapped = wrapper.exec(text)) !== null) {
160
+ tails.push(...deleteTargets(wrapped[2], ruleId));
161
+ if (wrapped[0].length === 0) wrapper.lastIndex++;
162
+ }
157
163
  } else if (ruleId === 'powershell-recursive-force-delete') {
158
- regex = /\bRemove-Item\b([^;|\n]+)/ig;
164
+ regex = /(?:^|[;|\n]\s*)(?:Remove-Item|ri|rm)\b([^;|\n]+)/ig;
159
165
  }
160
166
  if (!regex) return [];
161
167
 
@@ -190,6 +196,10 @@ function isWithin(target, root) {
190
196
 
191
197
  function scratchRoots(payload, sessionDir) {
192
198
  const roots = [os.tmpdir(), path.join(sessionDir, 'scratch')];
199
+ // Git Bash/MSYS exposes the Windows temp area as /tmp (and sometimes
200
+ // /var/tmp). Treat those aliases as scratch on Windows instead of gating a
201
+ // harmless temp cleanup merely because the host path syntax differs.
202
+ if (process.platform === 'win32') roots.push('/tmp', '/var/tmp');
193
203
  const declared = payload && (payload.scratchpad_path || payload.scratchpadPath || (payload.harness && payload.harness.scratchpadPath));
194
204
  if (declared) roots.push(declared);
195
205
  if (process.env.HARNESS_SCRATCH_DIR) roots.push(process.env.HARNESS_SCRATCH_DIR);
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Atomic Commit Check (PostToolUse: apply_patch)
4
- * PostToolUse can't block - the edit/write already happened. So this counts
5
- * apply_patch calls since the last commit and, once the count crosses a
6
- * threshold, feeds a strong reminder back to Claude (exit 2) to commit the
7
- * completed chunk before piling on more changes. Resets whenever HEAD moves.
3
+ * Atomic Commit Check (PostToolUse: Edit/Write/apply_patch)
4
+ * PostToolUse can't block - the edit/write already happened. Count only edits
5
+ * whose targets resolve inside the current workspace. Once the count crosses
6
+ * the threshold, emit a strong reminder only when the Git worktree actually
7
+ * has uncommitted changes. Resets whenever HEAD moves.
8
8
  * Fails open on any error - this is a nudge, never a hard stop.
9
9
  */
10
10
  const { execSync } = require('child_process');
@@ -15,6 +15,90 @@ const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
15
15
  const THRESHOLD = 6;
16
16
  const RENUDGE_EVERY = 3;
17
17
 
18
+ function toolNameOf(payload) {
19
+ return String((payload && (payload.tool_name || payload.toolName)) || '');
20
+ }
21
+
22
+ function toolInputOf(payload) {
23
+ return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
24
+ }
25
+
26
+ function pathApiFor(value) {
27
+ return /^[A-Za-z]:[\\/]/.test(String(value || '')) ? path.win32 : path;
28
+ }
29
+
30
+ function canonicalPath(value) {
31
+ const text = String(value || '');
32
+ if (!text) return null;
33
+ const api = pathApiFor(text);
34
+ const resolved = api.resolve(text);
35
+
36
+ // realpath() is necessary on macOS where /var is a symlink to /private/var.
37
+ // New Write targets may not exist yet, so canonicalize the nearest existing
38
+ // ancestor and append the missing path segments without requiring creation.
39
+ const missing = [];
40
+ let cursor = resolved;
41
+ while (cursor && !fs.existsSync(cursor)) {
42
+ const parent = api.dirname(cursor);
43
+ if (!parent || parent === cursor) break;
44
+ missing.unshift(api.basename(cursor));
45
+ cursor = parent;
46
+ }
47
+ if (!cursor || !fs.existsSync(cursor)) return resolved;
48
+
49
+ let real = fs.realpathSync.native ? fs.realpathSync.native(cursor) : fs.realpathSync(cursor);
50
+ for (const segment of missing) real = api.join(real, segment);
51
+ return api.normalize(real);
52
+ }
53
+
54
+ function isWithin(target, root) {
55
+ if (!target || !root) return false;
56
+ const api = pathApiFor(target) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
57
+ const resolvedTarget = canonicalPath(target);
58
+ const resolvedRoot = canonicalPath(root);
59
+ const relative = api.relative(resolvedRoot, resolvedTarget);
60
+ if (!relative) return true;
61
+ return relative !== '..' && !relative.startsWith(`..${api.sep}`) && !api.isAbsolute(relative);
62
+ }
63
+
64
+ function absoluteTarget(target, root) {
65
+ const text = String(target || '').trim();
66
+ if (!text) return null;
67
+ const api = pathApiFor(text) === path.win32 || pathApiFor(root) === path.win32 ? path.win32 : path;
68
+ return api.isAbsolute(text) ? api.normalize(text) : api.resolve(root, text);
69
+ }
70
+
71
+ function patchTargets(patchText) {
72
+ const targets = [];
73
+ const regex = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/gm;
74
+ let match;
75
+ while ((match = regex.exec(String(patchText || ''))) !== null) targets.push(match[1]);
76
+ return targets;
77
+ }
78
+
79
+ function editTargets(payload) {
80
+ const tool = toolNameOf(payload);
81
+ const input = toolInputOf(payload);
82
+ if (tool === 'Edit' || tool === 'Write') {
83
+ const target = input.file_path || input.filePath || input.path;
84
+ return target ? [String(target)] : [];
85
+ }
86
+ if (tool === 'apply_patch') {
87
+ return patchTargets(input.patch || input.command || input.content || '');
88
+ }
89
+ return [];
90
+ }
91
+
92
+ function touchesWorkspace(payload, root) {
93
+ const targets = editTargets(payload);
94
+ if (targets.length === 0) return false;
95
+ return targets.some(target => isWithin(absoluteTarget(target, root), root));
96
+ }
97
+
98
+ function writeState(stateFile, state) {
99
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
100
+ }
101
+
18
102
  function main(payload) {
19
103
  try {
20
104
  const root = getWorkspaceRoot(payload);
@@ -33,17 +117,32 @@ function main(payload) {
33
117
  state.editCount = 0;
34
118
  }
35
119
 
120
+ // Host scratchpads, memory stores, temp files, and any other target outside
121
+ // the repository must not contribute to a repository commit reminder.
122
+ if (!touchesWorkspace(payload, root)) {
123
+ writeState(stateFile, state);
124
+ process.exit(0);
125
+ }
126
+
36
127
  state.editCount += 1;
37
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
128
+ writeState(stateFile, state);
38
129
 
39
130
  const overThreshold = state.editCount >= THRESHOLD;
40
131
  const onNudgeBeat = overThreshold && (state.editCount - THRESHOLD) % RENUDGE_EVERY === 0;
41
132
 
42
133
  if (onNudgeBeat) {
43
134
  const statusRaw = execSync('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
44
- const changedFiles = statusRaw ? statusRaw.split('\n').length : 0;
45
- console.error(`[Atomic Commit Check] ${state.editCount} apply_patch calls since the last commit (${changedFiles} files currently changed).`);
46
- console.error(`If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.`);
135
+ if (!statusRaw) {
136
+ // Calls that produced no Git-visible change (or work already reverted)
137
+ // must not leave a stale counter that immediately re-nudges later.
138
+ state.editCount = 0;
139
+ writeState(stateFile, state);
140
+ process.exit(0);
141
+ }
142
+
143
+ const changedFiles = statusRaw.split('\n').length;
144
+ console.error(`[Atomic Commit Check] ${state.editCount} edits since the last commit (${changedFiles} files currently changed).`);
145
+ console.error('If a logically complete chunk of work is done, commit it now before continuing - large uncommitted diffs are harder to review and harder to recover from.');
47
146
  process.exit(2);
48
147
  }
49
148
 
@@ -66,3 +165,12 @@ process.stdin.on('error', () => {
66
165
  clearTimeout(timeout);
67
166
  main(null);
68
167
  });
168
+
169
+ module.exports = {
170
+ absoluteTarget,
171
+ canonicalPath,
172
+ editTargets,
173
+ isWithin,
174
+ patchTargets,
175
+ touchesWorkspace,
176
+ };
@@ -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.6.0",
4
+ "version": "0.6.1",
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",