@harness-engineering/cli 1.25.0 → 1.25.2
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/dist/bin/harness-mcp.js +1 -1
- package/dist/bin/harness.js +93 -2
- package/dist/{chunk-AZT567I6.js → chunk-5I3W4J5X.js} +38 -11
- package/dist/{chunk-RX7TUMBR.js → chunk-TBGIHPNA.js} +2 -0
- package/dist/hooks/adoption-tracker.js +189 -0
- package/dist/hooks/block-no-verify.js +50 -0
- package/dist/hooks/cost-tracker.js +66 -0
- package/dist/hooks/pre-compact-state.js +115 -0
- package/dist/hooks/profiles.ts +48 -0
- package/dist/hooks/protect-config.js +75 -0
- package/dist/hooks/quality-gate.js +131 -0
- package/dist/hooks/sentinel-post.js +159 -0
- package/dist/hooks/sentinel-pre.js +244 -0
- package/dist/hooks/telemetry-reporter.js +248 -0
- package/dist/index.js +2 -2
- package/dist/{mcp-2553PNUC.js → mcp-W3FLXSFF.js} +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook profile definitions.
|
|
3
|
+
*
|
|
4
|
+
* Profiles are additive: each higher tier includes all hooks from lower tiers.
|
|
5
|
+
* - minimal: safety floor (block-no-verify only)
|
|
6
|
+
* - standard: + protect-config, quality-gate, pre-compact-state (default)
|
|
7
|
+
* - strict: + cost-tracker, sentinel-pre, sentinel-post
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type HookProfile = 'minimal' | 'standard' | 'strict';
|
|
11
|
+
|
|
12
|
+
export interface HookScript {
|
|
13
|
+
/** Script filename without .js extension */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Claude Code hook event */
|
|
16
|
+
event: 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'Stop';
|
|
17
|
+
/** Tool matcher pattern */
|
|
18
|
+
matcher: string;
|
|
19
|
+
/** Minimum profile tier that includes this hook */
|
|
20
|
+
minProfile: HookProfile;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const HOOK_SCRIPTS: HookScript[] = [
|
|
24
|
+
{ name: 'block-no-verify', event: 'PreToolUse', matcher: 'Bash', minProfile: 'minimal' },
|
|
25
|
+
{ name: 'protect-config', event: 'PreToolUse', matcher: 'Write|Edit', minProfile: 'standard' },
|
|
26
|
+
{ name: 'quality-gate', event: 'PostToolUse', matcher: 'Edit|Write', minProfile: 'standard' },
|
|
27
|
+
{ name: 'pre-compact-state', event: 'PreCompact', matcher: '*', minProfile: 'standard' },
|
|
28
|
+
{ name: 'adoption-tracker', event: 'Stop', matcher: '*', minProfile: 'standard' },
|
|
29
|
+
{ name: 'telemetry-reporter', event: 'Stop', matcher: '*', minProfile: 'standard' },
|
|
30
|
+
{ name: 'cost-tracker', event: 'Stop', matcher: '*', minProfile: 'strict' },
|
|
31
|
+
{ name: 'sentinel-pre', event: 'PreToolUse', matcher: '*', minProfile: 'strict' },
|
|
32
|
+
{ name: 'sentinel-post', event: 'PostToolUse', matcher: '*', minProfile: 'strict' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const PROFILE_ORDER: HookProfile[] = ['minimal', 'standard', 'strict'];
|
|
36
|
+
|
|
37
|
+
function hooksForProfile(profile: HookProfile): string[] {
|
|
38
|
+
const profileIndex = PROFILE_ORDER.indexOf(profile);
|
|
39
|
+
return HOOK_SCRIPTS.filter((h) => PROFILE_ORDER.indexOf(h.minProfile) <= profileIndex).map(
|
|
40
|
+
(h) => h.name
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const PROFILES: Record<HookProfile, string[]> = {
|
|
45
|
+
minimal: hooksForProfile('minimal'),
|
|
46
|
+
standard: hooksForProfile('standard'),
|
|
47
|
+
strict: hooksForProfile('strict'),
|
|
48
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// protect-config.js — PreToolUse:Write/Edit hook
|
|
3
|
+
// Blocks modifications to linter/formatter config files.
|
|
4
|
+
// Fail-open: parse errors and unexpected exceptions log to stderr and exit 0.
|
|
5
|
+
// Exit codes: 0 = allow, 2 = block
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
|
|
11
|
+
// Protected config file patterns
|
|
12
|
+
const PROTECTED_PATTERNS = [
|
|
13
|
+
/^\.eslintrc/,
|
|
14
|
+
/^eslint\.config\./,
|
|
15
|
+
/^\.prettierrc/,
|
|
16
|
+
/^prettier\.config\./,
|
|
17
|
+
/^biome\.json$/,
|
|
18
|
+
/^biome\.jsonc$/,
|
|
19
|
+
/^\.ruff\.toml$/,
|
|
20
|
+
/^ruff\.toml$/,
|
|
21
|
+
/^\.stylelintrc/,
|
|
22
|
+
/^\.markdownlint/,
|
|
23
|
+
/^deno\.json$/,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function isProtected(filePath) {
|
|
27
|
+
const base = basename(filePath);
|
|
28
|
+
return PROTECTED_PATTERNS.some((pattern) => pattern.test(base));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(0, 'utf-8');
|
|
35
|
+
} catch {
|
|
36
|
+
process.stderr.write('[protect-config] Could not read stdin — allowing (fail-open)\n');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!raw.trim()) {
|
|
41
|
+
process.stderr.write('[protect-config] Empty stdin — allowing (fail-open)\n');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let input;
|
|
46
|
+
try {
|
|
47
|
+
input = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
process.stderr.write('[protect-config] Could not parse stdin JSON — allowing (fail-open)\n');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const filePath = input?.tool_input?.file_path;
|
|
55
|
+
|
|
56
|
+
if (typeof filePath !== 'string' || !filePath) {
|
|
57
|
+
process.stderr.write('[protect-config] Missing file_path in tool input — allowing (fail-open)\n');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isProtected(filePath)) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
`BLOCKED: Modification to protected config file: ${basename(filePath)}. Linter/formatter configs must not be weakened.\n`
|
|
64
|
+
);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} catch {
|
|
70
|
+
process.stderr.write('[protect-config] Unexpected error — allowing (fail-open)\n');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main();
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quality-gate.js — PostToolUse:Edit/Write hook
|
|
3
|
+
// Runs project formatter/linter after edits and warns on violations.
|
|
4
|
+
// Never blocks (always exits 0). Warnings go to stderr.
|
|
5
|
+
// Exit codes: 0 = allow (always)
|
|
6
|
+
|
|
7
|
+
import { readFileSync, accessSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
|
|
12
|
+
// Detection order: first match wins
|
|
13
|
+
const DETECTORS = [
|
|
14
|
+
{
|
|
15
|
+
configs: ['biome.json', 'biome.jsonc'],
|
|
16
|
+
cmd: 'npx',
|
|
17
|
+
args: ['biome', 'check'],
|
|
18
|
+
name: 'Biome',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
configs: [
|
|
22
|
+
'.prettierrc',
|
|
23
|
+
'.prettierrc.json',
|
|
24
|
+
'.prettierrc.yml',
|
|
25
|
+
'.prettierrc.yaml',
|
|
26
|
+
'.prettierrc.js',
|
|
27
|
+
'.prettierrc.cjs',
|
|
28
|
+
'.prettierrc.mjs',
|
|
29
|
+
'prettier.config.js',
|
|
30
|
+
'prettier.config.cjs',
|
|
31
|
+
'prettier.config.mjs',
|
|
32
|
+
],
|
|
33
|
+
cmd: 'npx',
|
|
34
|
+
args: ['prettier', '--check'],
|
|
35
|
+
name: 'Prettier',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
configs: ['.ruff.toml', 'ruff.toml'],
|
|
39
|
+
cmd: 'ruff',
|
|
40
|
+
args: ['check'],
|
|
41
|
+
name: 'Ruff',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function detectFormatter(cwd) {
|
|
46
|
+
for (const detector of DETECTORS) {
|
|
47
|
+
for (const config of detector.configs) {
|
|
48
|
+
try {
|
|
49
|
+
accessSync(join(cwd, config));
|
|
50
|
+
return detector;
|
|
51
|
+
} catch {
|
|
52
|
+
// Config not found, try next
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
let raw = '';
|
|
61
|
+
try {
|
|
62
|
+
raw = readFileSync(0, 'utf-8');
|
|
63
|
+
} catch {
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!raw.trim()) {
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let input;
|
|
72
|
+
try {
|
|
73
|
+
input = JSON.parse(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const filePath = input?.tool_input?.file_path ?? '';
|
|
80
|
+
const cwd = process.cwd();
|
|
81
|
+
|
|
82
|
+
// Special case: .go files use gofmt
|
|
83
|
+
if (typeof filePath === 'string' && filePath.endsWith('.go')) {
|
|
84
|
+
try {
|
|
85
|
+
const result = execFileSync('gofmt', ['-l', filePath], {
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
cwd,
|
|
88
|
+
timeout: 10000,
|
|
89
|
+
});
|
|
90
|
+
if (result.trim()) {
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`[quality-gate] gofmt found formatting issues in: ${result.trim()}\n`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// gofmt not available or failed — warn and continue
|
|
97
|
+
process.stderr.write('[quality-gate] gofmt check failed (tool may not be installed)\n');
|
|
98
|
+
}
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const detector = detectFormatter(cwd);
|
|
103
|
+
if (!detector) {
|
|
104
|
+
// No formatter detected — nothing to check
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
execFileSync(detector.cmd, detector.args, {
|
|
110
|
+
encoding: 'utf-8',
|
|
111
|
+
cwd,
|
|
112
|
+
timeout: 30000,
|
|
113
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
|
+
});
|
|
115
|
+
process.stderr.write(`[quality-gate] ${detector.name} check passed\n`);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Formatter found violations or failed to run — warn only
|
|
118
|
+
const output = err.stdout || err.stderr || '';
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`[quality-gate] ${detector.name} check reported issues:\n${output.slice(0, 500)}\n`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.exit(0);
|
|
125
|
+
} catch {
|
|
126
|
+
// Unexpected error — fail open
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main();
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global console */
|
|
3
|
+
// sentinel-post.js — PostToolUse:* hook
|
|
4
|
+
// Sentinel prompt injection defense — scans tool outputs for injection patterns.
|
|
5
|
+
// Exit codes: always 0 (PostToolUse cannot block)
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { resolve, dirname } from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
|
|
11
|
+
// Minimal inline patterns for when @harness-engineering/core isn't available.
|
|
12
|
+
// Keep in sync with @harness-engineering/core injection-patterns.ts ALL_PATTERNS.
|
|
13
|
+
// Covers all HIGH-severity patterns and key MEDIUM patterns for degraded-mode safety.
|
|
14
|
+
function inlineScan(text) {
|
|
15
|
+
console.error('[sentinel] Running in degraded mode: core import failed, using inline patterns');
|
|
16
|
+
const findings = [];
|
|
17
|
+
const lines = text.split('\n');
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
// HIGH: INJ-UNI-001 — Zero-width characters
|
|
21
|
+
// eslint-disable-next-line no-misleading-character-class -- intentional: detects zero-width chars for security
|
|
22
|
+
if (/[\u200B\u200C\u200D\uFEFF\u2060]/.test(line)) {
|
|
23
|
+
findings.push({ severity: 'high', ruleId: 'INJ-UNI-001', match: line.trim(), line: i + 1 });
|
|
24
|
+
}
|
|
25
|
+
// HIGH: INJ-UNI-002 — RTL/LTR override characters
|
|
26
|
+
if (/[\u202A-\u202E\u2066-\u2069]/.test(line)) {
|
|
27
|
+
findings.push({ severity: 'high', ruleId: 'INJ-UNI-002', match: line.trim(), line: i + 1 });
|
|
28
|
+
}
|
|
29
|
+
// HIGH: INJ-REROL-001 — Ignore previous instructions
|
|
30
|
+
if (/(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context|rules?|guidelines?)/i.test(line)) {
|
|
31
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-001', match: line.trim(), line: i + 1 });
|
|
32
|
+
}
|
|
33
|
+
// HIGH: INJ-REROL-002 — Role reassignment
|
|
34
|
+
if (/you\s+are\s+now\s+(?:a\s+|an\s+)?(?:new\s+)?(?:helpful\s+)?(?:my\s+)?(?:\w+\s+)?(?:assistant|agent|AI|bot|chatbot|system|persona)\b/i.test(line)) {
|
|
35
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-002', match: line.trim(), line: i + 1 });
|
|
36
|
+
}
|
|
37
|
+
// HIGH: INJ-REROL-003 — Direct instruction override
|
|
38
|
+
if (/(?:new\s+)?(?:system\s+)?(?:instruction|directive|role|persona)\s*[:=]\s*/i.test(line)) {
|
|
39
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-003', match: line.trim(), line: i + 1 });
|
|
40
|
+
}
|
|
41
|
+
// HIGH: INJ-PERM-001 — Enable all tools/permissions
|
|
42
|
+
if (/(?:allow|enable|grant)\s+all\s+(?:tools?|permissions?|access)/i.test(line)) {
|
|
43
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-001', match: line.trim(), line: i + 1 });
|
|
44
|
+
}
|
|
45
|
+
// HIGH: INJ-PERM-002 — Disable safety/security
|
|
46
|
+
if (/(?:disable|turn\s+off|remove|bypass)\s+(?:all\s+)?(?:safety|security|restrictions?|guardrails?|protections?|checks?)/i.test(line)) {
|
|
47
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-002', match: line.trim(), line: i + 1 });
|
|
48
|
+
}
|
|
49
|
+
// HIGH: INJ-PERM-003 — Auto-approve directive
|
|
50
|
+
if (/(?:auto[- ]?approve|--no-verify|--dangerously-skip-permissions)/i.test(line)) {
|
|
51
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-003', match: line.trim(), line: i + 1 });
|
|
52
|
+
}
|
|
53
|
+
// HIGH: INJ-ENC-001 — Suspicious base64
|
|
54
|
+
if (/(?<!Bearer\s)(?<![:])(?<![A-Za-z0-9/])(?!eyJ)(?:[A-Za-z0-9+/]{4}){7,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![A-Za-z0-9/])/.test(line)) {
|
|
55
|
+
findings.push({ severity: 'high', ruleId: 'INJ-ENC-001', match: line.trim(), line: i + 1 });
|
|
56
|
+
}
|
|
57
|
+
// MEDIUM: INJ-CTX-001 — System prompt claims
|
|
58
|
+
if (/(?:the\s+)?(?:system\s+prompt|system\s+message|hidden\s+instructions?)\s+(?:says?|tells?|instructs?|contains?|is)/i.test(line)) {
|
|
59
|
+
findings.push({ severity: 'medium', ruleId: 'INJ-CTX-001', match: line.trim(), line: i + 1 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
let raw = '';
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(0, 'utf-8');
|
|
69
|
+
} catch {
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!raw.trim()) {
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let input;
|
|
78
|
+
try {
|
|
79
|
+
input = JSON.parse(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const toolName = input?.tool_name ?? '';
|
|
86
|
+
const toolOutput = input?.tool_output ?? '';
|
|
87
|
+
const sessionId = input?.session_id;
|
|
88
|
+
const workspaceRoot = process.cwd();
|
|
89
|
+
|
|
90
|
+
if (!toolOutput || typeof toolOutput !== 'string') {
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let findings;
|
|
95
|
+
try {
|
|
96
|
+
const core = await import('@harness-engineering/core');
|
|
97
|
+
findings = core.scanForInjection(toolOutput);
|
|
98
|
+
} catch {
|
|
99
|
+
findings = inlineScan(toolOutput);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const actionable = findings.filter((f) => f.severity === 'high' || f.severity === 'medium');
|
|
103
|
+
|
|
104
|
+
if (actionable.length > 0) {
|
|
105
|
+
try {
|
|
106
|
+
const core = await import('@harness-engineering/core');
|
|
107
|
+
core.writeTaint(
|
|
108
|
+
workspaceRoot,
|
|
109
|
+
sessionId,
|
|
110
|
+
`Injection pattern detected in PostToolUse:${toolName} result`,
|
|
111
|
+
actionable,
|
|
112
|
+
`PostToolUse:${toolName}`
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
// Fallback inline taint writer — merges with existing taint state
|
|
116
|
+
try {
|
|
117
|
+
const id = sessionId || 'default';
|
|
118
|
+
const taintPath = resolve(workspaceRoot, '.harness', `session-taint-${id}.json`);
|
|
119
|
+
mkdirSync(dirname(taintPath), { recursive: true });
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const maxSev = actionable.some((f) => f.severity === 'high') ? 'high' : 'medium';
|
|
122
|
+
const newFindings = actionable.map((f) => ({
|
|
123
|
+
ruleId: f.ruleId, severity: f.severity, match: f.match,
|
|
124
|
+
source: `PostToolUse:${toolName}`, detectedAt: now,
|
|
125
|
+
}));
|
|
126
|
+
// Read and merge existing taint state to preserve earlier taintedAt and findings
|
|
127
|
+
let existing = null;
|
|
128
|
+
try { existing = JSON.parse(readFileSync(taintPath, 'utf-8')); } catch { /* no existing */ }
|
|
129
|
+
const state = {
|
|
130
|
+
sessionId: id,
|
|
131
|
+
taintedAt: existing?.taintedAt ?? now,
|
|
132
|
+
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
|
133
|
+
reason: existing?.reason ?? `Injection pattern detected in PostToolUse:${toolName} result`,
|
|
134
|
+
severity: maxSev,
|
|
135
|
+
findings: [...(existing?.findings ?? []), ...newFindings],
|
|
136
|
+
};
|
|
137
|
+
writeFileSync(taintPath, JSON.stringify(state, null, 2) + '\n');
|
|
138
|
+
} catch { /* best-effort */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const f of actionable) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
`Sentinel [${f.severity}] ${f.ruleId}: detected in ${toolName} output\n`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const low = findings.filter((f) => f.severity === 'low');
|
|
149
|
+
for (const f of low) {
|
|
150
|
+
process.stderr.write(`Sentinel [low] ${f.ruleId}: ${f.match.slice(0, 80)}\n`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
process.exit(0);
|
|
154
|
+
} catch {
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global console */
|
|
3
|
+
// sentinel-pre.js — PreToolUse:* hook
|
|
4
|
+
// Sentinel prompt injection defense — scans tool inputs for injection patterns
|
|
5
|
+
// and blocks destructive operations during tainted sessions.
|
|
6
|
+
// Exit codes: 0 = allow, 2 = block
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { resolve, dirname } from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
|
|
12
|
+
// Destructive tool patterns blocked during taint.
|
|
13
|
+
// These are intentionally inline — this check runs before the @harness-engineering/core
|
|
14
|
+
// import attempt to ensure enforcement even when core is unavailable.
|
|
15
|
+
// Keep in sync with DESTRUCTIVE_BASH exported from @harness-engineering/core injection-patterns.ts.
|
|
16
|
+
const DESTRUCTIVE_BASH = [
|
|
17
|
+
/\bgit\s+push\b/,
|
|
18
|
+
/\bgit\s+commit\b/,
|
|
19
|
+
/\brm\s+-rf?\b/,
|
|
20
|
+
/\brm\s+-r\b/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function isDestructiveBash(command) {
|
|
24
|
+
return DESTRUCTIVE_BASH.some((p) => p.test(command));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isOutsideWorkspace(filePath, workspaceRoot) {
|
|
28
|
+
if (!filePath || !workspaceRoot) return false;
|
|
29
|
+
const resolved = resolve(workspaceRoot, filePath);
|
|
30
|
+
// Resolve symlinks to prevent bypass via symlink pointing outside workspace
|
|
31
|
+
let realResolved = resolved;
|
|
32
|
+
try { realResolved = realpathSync(resolved); } catch { /* path doesn't exist yet — use resolved */ }
|
|
33
|
+
return !realResolved.startsWith(workspaceRoot);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Minimal inline patterns for when @harness-engineering/core isn't available.
|
|
37
|
+
// Keep in sync with @harness-engineering/core injection-patterns.ts ALL_PATTERNS.
|
|
38
|
+
// Covers all HIGH-severity patterns and key MEDIUM patterns for degraded-mode safety.
|
|
39
|
+
function inlineScan(text) {
|
|
40
|
+
console.error('[sentinel] Running in degraded mode: core import failed, using inline patterns');
|
|
41
|
+
const findings = [];
|
|
42
|
+
const lines = text.split('\n');
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
// HIGH: INJ-UNI-001 — Zero-width characters
|
|
46
|
+
// eslint-disable-next-line no-misleading-character-class -- intentional: detects zero-width chars for security
|
|
47
|
+
if (/[\u200B\u200C\u200D\uFEFF\u2060]/.test(line)) {
|
|
48
|
+
findings.push({ severity: 'high', ruleId: 'INJ-UNI-001', match: line.trim(), line: i + 1 });
|
|
49
|
+
}
|
|
50
|
+
// HIGH: INJ-UNI-002 — RTL/LTR override characters
|
|
51
|
+
if (/[\u202A-\u202E\u2066-\u2069]/.test(line)) {
|
|
52
|
+
findings.push({ severity: 'high', ruleId: 'INJ-UNI-002', match: line.trim(), line: i + 1 });
|
|
53
|
+
}
|
|
54
|
+
// HIGH: INJ-REROL-001 — Ignore previous instructions
|
|
55
|
+
if (/(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context|rules?|guidelines?)/i.test(line)) {
|
|
56
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-001', match: line.trim(), line: i + 1 });
|
|
57
|
+
}
|
|
58
|
+
// HIGH: INJ-REROL-002 — Role reassignment
|
|
59
|
+
if (/you\s+are\s+now\s+(?:a\s+|an\s+)?(?:new\s+)?(?:helpful\s+)?(?:my\s+)?(?:\w+\s+)?(?:assistant|agent|AI|bot|chatbot|system|persona)\b/i.test(line)) {
|
|
60
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-002', match: line.trim(), line: i + 1 });
|
|
61
|
+
}
|
|
62
|
+
// HIGH: INJ-REROL-003 — Direct instruction override
|
|
63
|
+
if (/(?:new\s+)?(?:system\s+)?(?:instruction|directive|role|persona)\s*[:=]\s*/i.test(line)) {
|
|
64
|
+
findings.push({ severity: 'high', ruleId: 'INJ-REROL-003', match: line.trim(), line: i + 1 });
|
|
65
|
+
}
|
|
66
|
+
// HIGH: INJ-PERM-001 — Enable all tools/permissions
|
|
67
|
+
if (/(?:allow|enable|grant)\s+all\s+(?:tools?|permissions?|access)/i.test(line)) {
|
|
68
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-001', match: line.trim(), line: i + 1 });
|
|
69
|
+
}
|
|
70
|
+
// HIGH: INJ-PERM-002 — Disable safety/security
|
|
71
|
+
if (/(?:disable|turn\s+off|remove|bypass)\s+(?:all\s+)?(?:safety|security|restrictions?|guardrails?|protections?|checks?)/i.test(line)) {
|
|
72
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-002', match: line.trim(), line: i + 1 });
|
|
73
|
+
}
|
|
74
|
+
// HIGH: INJ-PERM-003 — Auto-approve directive
|
|
75
|
+
if (/(?:auto[- ]?approve|--no-verify|--dangerously-skip-permissions)/i.test(line)) {
|
|
76
|
+
findings.push({ severity: 'high', ruleId: 'INJ-PERM-003', match: line.trim(), line: i + 1 });
|
|
77
|
+
}
|
|
78
|
+
// HIGH: INJ-ENC-001 — Suspicious base64
|
|
79
|
+
if (/(?<!Bearer\s)(?<![:])(?<![A-Za-z0-9/])(?!eyJ)(?:[A-Za-z0-9+/]{4}){7,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![A-Za-z0-9/])/.test(line)) {
|
|
80
|
+
findings.push({ severity: 'high', ruleId: 'INJ-ENC-001', match: line.trim(), line: i + 1 });
|
|
81
|
+
}
|
|
82
|
+
// MEDIUM: INJ-CTX-001 — System prompt claims
|
|
83
|
+
if (/(?:the\s+)?(?:system\s+prompt|system\s+message|hidden\s+instructions?)\s+(?:says?|tells?|instructs?|contains?|is)/i.test(line)) {
|
|
84
|
+
findings.push({ severity: 'medium', ruleId: 'INJ-CTX-001', match: line.trim(), line: i + 1 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractText(toolName, toolInput) {
|
|
91
|
+
if (toolName === 'Bash') return toolInput?.command ?? '';
|
|
92
|
+
if (toolName === 'Write') return toolInput?.content ?? '';
|
|
93
|
+
if (toolName === 'Edit') return `${toolInput?.old_string ?? ''}\n${toolInput?.new_string ?? ''}`;
|
|
94
|
+
if (toolName === 'Read') return toolInput?.file_path ?? '';
|
|
95
|
+
const parts = [];
|
|
96
|
+
for (const value of Object.values(toolInput || {})) {
|
|
97
|
+
if (typeof value === 'string') parts.push(value);
|
|
98
|
+
}
|
|
99
|
+
return parts.join('\n') || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function main() {
|
|
103
|
+
let raw = '';
|
|
104
|
+
try {
|
|
105
|
+
raw = readFileSync(0, 'utf-8');
|
|
106
|
+
} catch {
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!raw.trim()) {
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let input;
|
|
115
|
+
try {
|
|
116
|
+
input = JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const toolName = input?.tool_name ?? '';
|
|
123
|
+
const toolInput = input?.tool_input ?? {};
|
|
124
|
+
const sessionId = input?.session_id;
|
|
125
|
+
const workspaceRoot = process.cwd();
|
|
126
|
+
|
|
127
|
+
// Step 1: Check taint state — block destructive ops if tainted
|
|
128
|
+
let tainted = false;
|
|
129
|
+
try {
|
|
130
|
+
const taintPath = resolve(
|
|
131
|
+
workspaceRoot,
|
|
132
|
+
'.harness',
|
|
133
|
+
`session-taint-${sessionId || 'default'}.json`
|
|
134
|
+
);
|
|
135
|
+
const taintRaw = readFileSync(taintPath, 'utf-8');
|
|
136
|
+
const taintState = JSON.parse(taintRaw);
|
|
137
|
+
|
|
138
|
+
const expiresAt = new Date(taintState.expiresAt);
|
|
139
|
+
if (new Date() >= expiresAt) {
|
|
140
|
+
try { unlinkSync(taintPath); } catch { /* ignore */ }
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
'Sentinel: session taint expired. Destructive operations re-enabled.\n'
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
tainted = true;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// No taint file or malformed — not tainted
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (tainted) {
|
|
152
|
+
if (toolName === 'Bash') {
|
|
153
|
+
const command = toolInput?.command ?? '';
|
|
154
|
+
if (isDestructiveBash(command)) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`BLOCKED by Sentinel: "${toolName}" blocked during tainted session. ` +
|
|
157
|
+
`Destructive operations are restricted. Run "harness taint clear" to lift.\n`
|
|
158
|
+
);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (toolName === 'Write' || toolName === 'Edit') {
|
|
164
|
+
const filePath = toolInput?.file_path ?? '';
|
|
165
|
+
if (isOutsideWorkspace(filePath, workspaceRoot)) {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`BLOCKED by Sentinel: "${toolName}" to "${filePath}" blocked during tainted session. ` +
|
|
168
|
+
`File is outside workspace. Run "harness taint clear" to lift.\n`
|
|
169
|
+
);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Step 2: Scan tool inputs for injection patterns
|
|
176
|
+
const textToScan = extractText(toolName, toolInput);
|
|
177
|
+
if (textToScan) {
|
|
178
|
+
let findings;
|
|
179
|
+
try {
|
|
180
|
+
const core = await import('@harness-engineering/core');
|
|
181
|
+
findings = core.scanForInjection(textToScan);
|
|
182
|
+
} catch {
|
|
183
|
+
findings = inlineScan(textToScan);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const actionable = findings.filter((f) => f.severity === 'high' || f.severity === 'medium');
|
|
187
|
+
|
|
188
|
+
if (actionable.length > 0) {
|
|
189
|
+
try {
|
|
190
|
+
const core = await import('@harness-engineering/core');
|
|
191
|
+
core.writeTaint(
|
|
192
|
+
workspaceRoot,
|
|
193
|
+
sessionId,
|
|
194
|
+
`Injection pattern detected in PreToolUse:${toolName} input`,
|
|
195
|
+
actionable,
|
|
196
|
+
`PreToolUse:${toolName}`
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
// Fallback inline taint writer — merges with existing taint state
|
|
200
|
+
try {
|
|
201
|
+
const id = sessionId || 'default';
|
|
202
|
+
const taintPath = resolve(workspaceRoot, '.harness', `session-taint-${id}.json`);
|
|
203
|
+
mkdirSync(dirname(taintPath), { recursive: true });
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
const maxSev = actionable.some((f) => f.severity === 'high') ? 'high' : 'medium';
|
|
206
|
+
const newFindings = actionable.map((f) => ({
|
|
207
|
+
ruleId: f.ruleId, severity: f.severity, match: f.match,
|
|
208
|
+
source: `PreToolUse:${toolName}`, detectedAt: now,
|
|
209
|
+
}));
|
|
210
|
+
// Read and merge existing taint state to preserve earlier taintedAt and findings
|
|
211
|
+
let existing = null;
|
|
212
|
+
try { existing = JSON.parse(readFileSync(taintPath, 'utf-8')); } catch { /* no existing */ }
|
|
213
|
+
const state = {
|
|
214
|
+
sessionId: id,
|
|
215
|
+
taintedAt: existing?.taintedAt ?? now,
|
|
216
|
+
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
|
217
|
+
reason: existing?.reason ?? `Injection pattern detected in PreToolUse:${toolName} input`,
|
|
218
|
+
severity: maxSev,
|
|
219
|
+
findings: [...(existing?.findings ?? []), ...newFindings],
|
|
220
|
+
};
|
|
221
|
+
writeFileSync(taintPath, JSON.stringify(state, null, 2) + '\n');
|
|
222
|
+
} catch { /* best-effort */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const f of actionable) {
|
|
226
|
+
process.stderr.write(
|
|
227
|
+
`Sentinel [${f.severity}] ${f.ruleId}: detected in ${toolName} input\n`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const low = findings.filter((f) => f.severity === 'low');
|
|
233
|
+
for (const f of low) {
|
|
234
|
+
process.stderr.write(`Sentinel [low] ${f.ruleId}: ${f.match.slice(0, 80)}\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
process.exit(0);
|
|
239
|
+
} catch {
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
main();
|