harness-everything 0.5.3 → 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 +12 -0
- package/behavioral-evals/PAIRED-BENCHMARK.md +186 -0
- package/behavioral-evals/README.md +46 -0
- package/behavioral-evals/opencode-hardlock-live.js +301 -0
- package/behavioral-evals/paired-benchmark.js +781 -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/ci/mechanism-31-opencode-hardlock-evidence.test.js +138 -0
- package/ci/mechanism-32-paired-benchmark.test.js +254 -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 +4 -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
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
|
-
"version": "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": {
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"eval:behavioral": "node behavioral-evals/run.js run",
|
|
41
41
|
"eval:behavioral:validate": "node behavioral-evals/run.js validate",
|
|
42
42
|
"eval:behavioral:plugin": "node behavioral-evals/run-with-plugin.js run",
|
|
43
|
+
"eval:opencode:hardlock-live": "node behavioral-evals/opencode-hardlock-live.js run",
|
|
44
|
+
"eval:paired": "node behavioral-evals/paired-benchmark.js run",
|
|
45
|
+
"eval:paired:summarize": "node behavioral-evals/paired-benchmark.js summarize",
|
|
43
46
|
"test:ab": "node ci/ab-test-harness.js validate",
|
|
44
47
|
"test:ab:run": "node ci/ab-test-harness.js run",
|
|
45
48
|
"harness:reset": "node hooks/scripts/reset-circuit-breaker.js",
|
|
@@ -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.
|
|
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",
|