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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +6 -0
- package/ci/mechanism-2ac-atomic-commit-check.test.js +111 -0
- package/ci/mechanism-2ad-action-gate-classifier-hardening.test.js +90 -0
- package/hooks/scripts/action-gate-rules.json +10 -10
- package/hooks/scripts/action-gate.js +21 -11
- package/hooks/scripts/atomic-commit-check.js +117 -9
- 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/action-gate-rules.json +10 -10
- package/plugins/harness-everything/hooks/scripts/action-gate.js +21 -11
- package/plugins/harness-everything/hooks/scripts/atomic-commit-check.js +117 -9
- 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.6.
|
|
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.
|
|
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": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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": ["
|
|
39
|
-
"pattern": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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'], '\\
|
|
24
|
-
['git-reset-hard', ['Bash', 'PowerShell'], '\\
|
|
25
|
-
['git-clean-force-delete', ['Bash', 'PowerShell'], '\\
|
|
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', ['
|
|
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'], '\\
|
|
30
|
-
['github-release-create', ['Bash', 'PowerShell'], '\\
|
|
31
|
-
['production-deploy', ['Bash', 'PowerShell'], '\\
|
|
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 =
|
|
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.
|
|
5
|
-
*
|
|
6
|
-
* threshold,
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
};
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{
|
|
5
5
|
"id": "git-force-push",
|
|
6
6
|
"tools": ["Bash", "PowerShell"],
|
|
7
|
-
"pattern": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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": ["
|
|
39
|
-
"pattern": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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": "\\
|
|
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'], '\\
|
|
24
|
-
['git-reset-hard', ['Bash', 'PowerShell'], '\\
|
|
25
|
-
['git-clean-force-delete', ['Bash', 'PowerShell'], '\\
|
|
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', ['
|
|
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'], '\\
|
|
30
|
-
['github-release-create', ['Bash', 'PowerShell'], '\\
|
|
31
|
-
['production-deploy', ['Bash', 'PowerShell'], '\\
|
|
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 =
|
|
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.
|
|
5
|
-
*
|
|
6
|
-
* threshold,
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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",
|