@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.
@@ -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();