@harness-engineering/cli 5.0.0 → 6.0.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.
Files changed (57) hide show
  1. package/dist/{agents-md-GOFDOUEI.js → agents-md-OTKZE5HH.js} +4 -4
  2. package/dist/{analyzer-XRCV63KC-CUGBENHK.js → analyzer-XRCV63KC-FALMS3MO.js} +2 -2
  3. package/dist/{architecture-UJZTBKJ2.js → architecture-3VYDN7MQ.js} +5 -5
  4. package/dist/{assess-project-S3UOQUQW.js → assess-project-27X2FSZZ.js} +1 -1
  5. package/dist/bin/harness-mcp.js +15 -15
  6. package/dist/bin/harness.js +25 -25
  7. package/dist/{check-phase-gate-SQ2X2Q7T.js → check-phase-gate-IXLFZ6YH.js} +5 -5
  8. package/dist/{chunk-FXO3EODY.js → chunk-354MCPT3.js} +3 -3
  9. package/dist/{chunk-OIMJWYWF.js → chunk-3H7JA24B.js} +7 -7
  10. package/dist/{chunk-WHPZG5TX.js → chunk-3MKQKNY3.js} +6 -6
  11. package/dist/{chunk-DPG2WNSN.js → chunk-6Y6M3AZA.js} +3 -3
  12. package/dist/{chunk-JGW4CHIJ.js → chunk-BMNHLKBA.js} +2 -2
  13. package/dist/{chunk-CFK4RJFP.js → chunk-CTMHHMHQ.js} +15 -3
  14. package/dist/{chunk-RD75T4XK.js → chunk-D7QXGI6E.js} +2 -2
  15. package/dist/{chunk-4ITYAEOA.js → chunk-EFWRZHZ7.js} +101 -97
  16. package/dist/{chunk-V6QYVM6K.js → chunk-IOJGLIEQ.js} +2 -2
  17. package/dist/{chunk-DU6AUYTA.js → chunk-IXGYIQSR.js} +1 -1
  18. package/dist/{chunk-T2PQWRRK.js → chunk-JJLOWMGC.js} +2 -2
  19. package/dist/{chunk-YE7NZ44F.js → chunk-KPBGAB35.js} +2 -2
  20. package/dist/{chunk-OWJTMHVP.js → chunk-L5UKNGC5.js} +2 -2
  21. package/dist/{chunk-R5C7ZFON.js → chunk-LHWBQD7F.js} +10 -1
  22. package/dist/{chunk-KI64IQLS.js → chunk-LISEPDVT.js} +5 -5
  23. package/dist/{chunk-UJW4POA3.js → chunk-MEIUXTFY.js} +2 -2
  24. package/dist/{chunk-RPOBWUKV.js → chunk-NZ5AXKRA.js} +1 -1
  25. package/dist/{chunk-6RXT67Q4.js → chunk-QHFXVVUY.js} +2 -2
  26. package/dist/{chunk-SQDQMVNE.js → chunk-S2ETJDQJ.js} +6 -6
  27. package/dist/{chunk-AIFPI3UZ.js → chunk-TM4KFUWP.js} +2 -2
  28. package/dist/{chunk-PSFJ5YKQ.js → chunk-VGDZAWCJ.js} +335 -177
  29. package/dist/{chunk-DFWM2SNS.js → chunk-VRA64HYI.js} +2 -2
  30. package/dist/{chunk-DSXUS6KG.js → chunk-WAFTJFT3.js} +3 -3
  31. package/dist/{chunk-7BBQ5C7I.js → chunk-XF6RE3JQ.js} +2 -2
  32. package/dist/{chunk-XC7NR63Q.js → chunk-XNHKXELN.js} +2 -2
  33. package/dist/{ci-workflow-5WHUEHOF.js → ci-workflow-Y5GBKNZP.js} +4 -4
  34. package/dist/{dist-SOKOW7BC.js → dist-4CZLCE33.js} +5 -3
  35. package/dist/{dist-O7MTFDG5.js → dist-S3KCRI7X.js} +3 -1
  36. package/dist/{docs-AEHPPDKX.js → docs-3MWDPK4S.js} +5 -5
  37. package/dist/{engine-HYM5SLCD.js → engine-RJXQ5DHK.js} +4 -4
  38. package/dist/{entropy-6KH5G3NR.js → entropy-OX5LSM4S.js} +5 -5
  39. package/dist/{feedback-AUP2JK43.js → feedback-OODAP2G2.js} +1 -1
  40. package/dist/{generate-agent-definitions-OH4RF445.js → generate-agent-definitions-CCREQZEA.js} +5 -5
  41. package/dist/hooks/adoption-tracker.js +41 -25
  42. package/dist/hooks/pre-compact-state.js +45 -27
  43. package/dist/hooks/sentinel-post.js +125 -102
  44. package/dist/hooks/sentinel-pre.js +70 -53
  45. package/dist/hooks/telemetry-reporter.js +44 -25
  46. package/dist/index.d.ts +655 -0
  47. package/dist/index.js +25 -25
  48. package/dist/{loader-KWRPA7D5.js → loader-ZICOJBVX.js} +4 -4
  49. package/dist/{mcp-JTPO5RAK.js → mcp-XX6EIBLL.js} +15 -15
  50. package/dist/{performance-HW53ZIAF.js → performance-OLZVGMAY.js} +5 -5
  51. package/dist/{review-pipeline-2R7UBFTG.js → review-pipeline-3QPHMWPC.js} +4 -4
  52. package/dist/{runtime-BB35PFZY.js → runtime-ZFFIH76Z.js} +4 -4
  53. package/dist/{security-IDJHGBBH.js → security-7YW3UK2C.js} +1 -1
  54. package/dist/{state-events-H3GGBKTB.js → state-events-PTWAEGJ2.js} +4 -4
  55. package/dist/{validate-6SDGP7AH.js → validate-H5SB7X3Z.js} +5 -5
  56. package/dist/{validate-cross-check-OF3FPM32.js → validate-cross-check-YYVYMSPA.js} +4 -4
  57. package/package.json +8 -8
@@ -11,73 +11,146 @@ import process from 'node:process';
11
11
  // Minimal inline patterns for when @harness-engineering/core isn't available.
12
12
  // Keep in sync with @harness-engineering/core injection-patterns.ts ALL_PATTERNS.
13
13
  // Covers all HIGH-severity patterns and key MEDIUM patterns for degraded-mode safety.
14
+ //
15
+ // Rules are declared as a data table and applied in order, one regex per line.
16
+ // This is a pure structural extraction of the original per-rule `if` blocks —
17
+ // every rule's pattern, severity, ruleId, and ordering is byte-for-byte preserved.
18
+ const INLINE_RULES = [
19
+ // HIGH: INJ-UNI-001 — Zero-width characters
20
+ // eslint-disable-next-line no-misleading-character-class -- intentional: detects zero-width chars for security
21
+ { severity: 'high', ruleId: 'INJ-UNI-001', pattern: /[\u200B\u200C\u200D\uFEFF\u2060]/ },
22
+ // HIGH: INJ-UNI-002 — RTL/LTR override characters
23
+ { severity: 'high', ruleId: 'INJ-UNI-002', pattern: /[\u202A-\u202E\u2066-\u2069]/ },
24
+ // HIGH: INJ-REROL-001 — Ignore previous instructions
25
+ { severity: 'high', ruleId: 'INJ-REROL-001', pattern: /(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context|rules?|guidelines?)/i },
26
+ // HIGH: INJ-REROL-002 — Role reassignment
27
+ { severity: 'high', ruleId: 'INJ-REROL-002', pattern: /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 },
28
+ // HIGH: INJ-REROL-003 — Direct instruction override
29
+ { severity: 'high', ruleId: 'INJ-REROL-003', pattern: /(?:new\s+)?(?:system\s+)?(?:instruction|directive|role|persona)\s*[:=]\s*/i },
30
+ // HIGH: INJ-PERM-001 — Enable all tools/permissions
31
+ { severity: 'high', ruleId: 'INJ-PERM-001', pattern: /(?:allow|enable|grant)\s+all\s+(?:tools?|permissions?|access)/i },
32
+ // HIGH: INJ-PERM-002 — Disable safety/security
33
+ { severity: 'high', ruleId: 'INJ-PERM-002', pattern: /(?:disable|turn\s+off|remove|bypass)\s+(?:all\s+)?(?:safety|security|restrictions?|guardrails?|protections?|checks?)/i },
34
+ // HIGH: INJ-PERM-003 — Auto-approve directive
35
+ { severity: 'high', ruleId: 'INJ-PERM-003', pattern: /(?:auto[- ]?approve|--no-verify|--dangerously-skip-permissions)/i },
36
+ // HIGH: INJ-ENC-001 — Suspicious base64
37
+ { severity: 'high', ruleId: 'INJ-ENC-001', pattern: /(?<!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/])/ },
38
+ // MEDIUM: INJ-CTX-001 — System prompt claims
39
+ { severity: 'medium', ruleId: 'INJ-CTX-001', pattern: /(?:the\s+)?(?:system\s+prompt|system\s+message|hidden\s+instructions?)\s+(?:says?|tells?|instructs?|contains?|is)/i },
40
+ ];
41
+
42
+ /** Apply every inline rule to one line, appending findings in rule order. */
43
+ function scanLine(line, lineNumber, findings) {
44
+ for (const rule of INLINE_RULES) {
45
+ if (rule.pattern.test(line)) {
46
+ findings.push({
47
+ severity: rule.severity,
48
+ ruleId: rule.ruleId,
49
+ match: line.trim(),
50
+ line: lineNumber,
51
+ });
52
+ }
53
+ }
54
+ }
55
+
14
56
  function inlineScan(text) {
15
57
  console.error('[sentinel] Running in degraded mode: core import failed, using inline patterns');
16
58
  const findings = [];
17
59
  const lines = text.split('\n');
18
60
  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
+ scanLine(lines[i], i + 1, findings);
61
62
  }
62
63
  return findings;
63
64
  }
64
65
 
65
- async function main() {
66
- let raw = '';
66
+ /** Read stdin (fd 0) and parse it as JSON. Returns null when it should exit-0. */
67
+ function readStdinJson() {
68
+ let raw;
67
69
  try {
68
70
  raw = readFileSync(0, 'utf-8');
69
71
  } catch {
70
- process.exit(0);
72
+ return null;
73
+ }
74
+ if (!raw.trim()) return null;
75
+ try {
76
+ return JSON.parse(raw);
77
+ } catch {
78
+ return null;
71
79
  }
80
+ }
72
81
 
73
- if (!raw.trim()) {
74
- process.exit(0);
82
+ /** Run injection detection via core, falling back to inline patterns. */
83
+ async function detectFindings(toolOutput) {
84
+ try {
85
+ const core = await import('@harness-engineering/core');
86
+ return core.scanForInjection(toolOutput);
87
+ } catch {
88
+ return inlineScan(toolOutput);
75
89
  }
90
+ }
76
91
 
77
- let input;
92
+ /**
93
+ * Fallback inline taint writer — merges with existing taint state.
94
+ * Used only when @harness-engineering/core.writeTaint is unavailable.
95
+ */
96
+ function writeTaintFallback(workspaceRoot, sessionId, toolName, actionable) {
78
97
  try {
79
- input = JSON.parse(raw);
98
+ const id = sessionId || 'default';
99
+ const taintPath = resolve(workspaceRoot, '.harness', `session-taint-${id}.json`);
100
+ mkdirSync(dirname(taintPath), { recursive: true });
101
+ const now = new Date().toISOString();
102
+ const maxSev = actionable.some((f) => f.severity === 'high') ? 'high' : 'medium';
103
+ const newFindings = actionable.map((f) => ({
104
+ ruleId: f.ruleId, severity: f.severity, match: f.match,
105
+ source: `PostToolUse:${toolName}`, detectedAt: now,
106
+ }));
107
+ // Read and merge existing taint state to preserve earlier taintedAt and findings
108
+ let existing = null;
109
+ try { existing = JSON.parse(readFileSync(taintPath, 'utf-8')); } catch { /* no existing */ }
110
+ const state = {
111
+ sessionId: id,
112
+ taintedAt: existing?.taintedAt ?? now,
113
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
114
+ reason: existing?.reason ?? `Injection pattern detected in PostToolUse:${toolName} result`,
115
+ severity: maxSev,
116
+ findings: [...(existing?.findings ?? []), ...newFindings],
117
+ };
118
+ writeFileSync(taintPath, JSON.stringify(state, null, 2) + '\n');
119
+ } catch { /* best-effort */ }
120
+ }
121
+
122
+ /** Persist taint via core.writeTaint, falling back to the inline writer. */
123
+ async function recordTaint(workspaceRoot, sessionId, toolName, actionable) {
124
+ try {
125
+ const core = await import('@harness-engineering/core');
126
+ core.writeTaint(
127
+ workspaceRoot,
128
+ sessionId,
129
+ `Injection pattern detected in PostToolUse:${toolName} result`,
130
+ actionable,
131
+ `PostToolUse:${toolName}`
132
+ );
80
133
  } catch {
134
+ writeTaintFallback(workspaceRoot, sessionId, toolName, actionable);
135
+ }
136
+ }
137
+
138
+ /** Emit stderr lines for actionable (high/medium) and low findings. */
139
+ function reportFindings(findings, actionable, toolName) {
140
+ for (const f of actionable) {
141
+ process.stderr.write(
142
+ `Sentinel [${f.severity}] ${f.ruleId}: detected in ${toolName} output\n`
143
+ );
144
+ }
145
+ const low = findings.filter((f) => f.severity === 'low');
146
+ for (const f of low) {
147
+ process.stderr.write(`Sentinel [low] ${f.ruleId}: ${f.match.slice(0, 80)}\n`);
148
+ }
149
+ }
150
+
151
+ async function main() {
152
+ const input = readStdinJson();
153
+ if (input === null) {
81
154
  process.exit(0);
82
155
  }
83
156
 
@@ -91,64 +164,14 @@ async function main() {
91
164
  process.exit(0);
92
165
  }
93
166
 
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
-
167
+ const findings = await detectFindings(toolOutput);
102
168
  const actionable = findings.filter((f) => f.severity === 'high' || f.severity === 'medium');
103
169
 
104
170
  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
- }
171
+ await recordTaint(workspaceRoot, sessionId, toolName, actionable);
146
172
  }
147
173
 
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
- }
174
+ reportFindings(findings, actionable, toolName);
152
175
 
153
176
  process.exit(0);
154
177
  } catch {
@@ -39,22 +39,82 @@ function isOutsideWorkspace(filePath, workspaceRoot) {
39
39
  return !realResolved.startsWith(workspaceRoot);
40
40
  }
41
41
 
42
- async function main() {
43
- let raw = '';
42
+ /** Read stdin (fd 0) and parse it as JSON. Returns null when it should exit-0. */
43
+ function readStdinJson() {
44
+ let raw;
44
45
  try {
45
46
  raw = readFileSync(0, 'utf-8');
46
47
  } catch {
47
- process.exit(0);
48
+ return null;
48
49
  }
49
-
50
- if (!raw.trim()) {
51
- process.exit(0);
50
+ if (!raw.trim()) return null;
51
+ try {
52
+ return JSON.parse(raw);
53
+ } catch {
54
+ return null;
52
55
  }
56
+ }
53
57
 
54
- let input;
58
+ /**
59
+ * Determine whether the session is currently tainted. Deletes and reports an
60
+ * expired taint file as a side effect (behavior preserved from inline logic).
61
+ */
62
+ function isSessionTainted(workspaceRoot, sessionId) {
55
63
  try {
56
- input = JSON.parse(raw);
64
+ const taintPath = resolve(
65
+ workspaceRoot,
66
+ '.harness',
67
+ `session-taint-${sessionId || 'default'}.json`
68
+ );
69
+ const taintRaw = readFileSync(taintPath, 'utf-8');
70
+ const taintState = JSON.parse(taintRaw);
71
+
72
+ const expiresAt = new Date(taintState.expiresAt);
73
+ if (new Date() >= expiresAt) {
74
+ try { unlinkSync(taintPath); } catch { /* ignore */ }
75
+ process.stderr.write(
76
+ 'Sentinel: session taint expired. Destructive operations re-enabled.\n'
77
+ );
78
+ return false;
79
+ }
80
+ return true;
57
81
  } catch {
82
+ // No taint file or malformed — not tainted
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Enforce taint restrictions for the given tool. Exits 2 (block) when a
89
+ * destructive Bash command or an out-of-workspace Write/Edit is attempted.
90
+ */
91
+ function enforceTaint(toolName, toolInput, workspaceRoot) {
92
+ if (toolName === 'Bash') {
93
+ const command = toolInput?.command ?? '';
94
+ if (isDestructiveBash(command)) {
95
+ process.stderr.write(
96
+ `BLOCKED by Sentinel: "${toolName}" blocked during tainted session. ` +
97
+ `Destructive operations are restricted. Run "harness taint clear" to lift.\n`
98
+ );
99
+ process.exit(2);
100
+ }
101
+ }
102
+
103
+ if (toolName === 'Write' || toolName === 'Edit') {
104
+ const filePath = toolInput?.file_path ?? '';
105
+ if (isOutsideWorkspace(filePath, workspaceRoot)) {
106
+ process.stderr.write(
107
+ `BLOCKED by Sentinel: "${toolName}" to "${filePath}" blocked during tainted session. ` +
108
+ `File is outside workspace. Run "harness taint clear" to lift.\n`
109
+ );
110
+ process.exit(2);
111
+ }
112
+ }
113
+ }
114
+
115
+ async function main() {
116
+ const input = readStdinJson();
117
+ if (input === null) {
58
118
  process.exit(0);
59
119
  }
60
120
 
@@ -66,51 +126,8 @@ async function main() {
66
126
 
67
127
  // Check taint state — block destructive ops if the session is already tainted.
68
128
  // Taint is written only by sentinel-post (from untrusted tool OUTPUT).
69
- let tainted = false;
70
- try {
71
- const taintPath = resolve(
72
- workspaceRoot,
73
- '.harness',
74
- `session-taint-${sessionId || 'default'}.json`
75
- );
76
- const taintRaw = readFileSync(taintPath, 'utf-8');
77
- const taintState = JSON.parse(taintRaw);
78
-
79
- const expiresAt = new Date(taintState.expiresAt);
80
- if (new Date() >= expiresAt) {
81
- try { unlinkSync(taintPath); } catch { /* ignore */ }
82
- process.stderr.write(
83
- 'Sentinel: session taint expired. Destructive operations re-enabled.\n'
84
- );
85
- } else {
86
- tainted = true;
87
- }
88
- } catch {
89
- // No taint file or malformed — not tainted
90
- }
91
-
92
- if (tainted) {
93
- if (toolName === 'Bash') {
94
- const command = toolInput?.command ?? '';
95
- if (isDestructiveBash(command)) {
96
- process.stderr.write(
97
- `BLOCKED by Sentinel: "${toolName}" blocked during tainted session. ` +
98
- `Destructive operations are restricted. Run "harness taint clear" to lift.\n`
99
- );
100
- process.exit(2);
101
- }
102
- }
103
-
104
- if (toolName === 'Write' || toolName === 'Edit') {
105
- const filePath = toolInput?.file_path ?? '';
106
- if (isOutsideWorkspace(filePath, workspaceRoot)) {
107
- process.stderr.write(
108
- `BLOCKED by Sentinel: "${toolName}" to "${filePath}" blocked during tainted session. ` +
109
- `File is outside workspace. Run "harness taint clear" to lift.\n`
110
- );
111
- process.exit(2);
112
- }
113
- }
129
+ if (isSessionTainted(workspaceRoot, sessionId)) {
130
+ enforceTaint(toolName, toolInput, workspaceRoot);
114
131
  }
115
132
 
116
133
  process.exit(0);
@@ -37,22 +37,37 @@ function sleep(ms) {
37
37
 
38
38
  // --- Consent ---
39
39
 
40
- function resolveConsent(cwd) {
41
- if (process.env.DO_NOT_TRACK === '1') return { allowed: false };
42
- if (process.env.HARNESS_TELEMETRY_OPTOUT === '1') return { allowed: false };
43
-
44
- const config = readJsonSafe(join(cwd, 'harness.config.json'));
45
- if (config?.telemetry?.enabled === false) return { allowed: false };
46
-
47
- const installId = getOrCreateInstallId(cwd);
48
-
49
- const identityFile = readJsonSafe(join(cwd, '.harness', 'telemetry.json'));
40
+ /** Copy string identity fields (project/team/alias) from the telemetry file. */
41
+ function readIdentityFile(cwd) {
50
42
  const identity = {};
51
- if (identityFile?.identity) {
52
- if (typeof identityFile.identity.project === 'string') identity.project = identityFile.identity.project;
53
- if (typeof identityFile.identity.team === 'string') identity.team = identityFile.identity.team;
54
- if (typeof identityFile.identity.alias === 'string') identity.alias = identityFile.identity.alias;
43
+ const identityFile = readJsonSafe(join(cwd, '.harness', 'telemetry.json'));
44
+ const src = identityFile?.identity;
45
+ if (src) {
46
+ if (typeof src.project === 'string') identity.project = src.project;
47
+ if (typeof src.team === 'string') identity.team = src.team;
48
+ if (typeof src.alias === 'string') identity.alias = src.alias;
49
+ }
50
+ return identity;
51
+ }
52
+
53
+ /** Resolve the git user.name, or '' when git is unavailable / unset. */
54
+ function resolveGitAlias(cwd) {
55
+ try {
56
+ return execFileSync('git', ['config', 'user.name'], {
57
+ cwd,
58
+ encoding: 'utf-8',
59
+ timeout: 2000,
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ }).trim();
62
+ } catch {
63
+ // Git not available or no user.name set
64
+ return '';
55
65
  }
66
+ }
67
+
68
+ /** Assemble the telemetry identity, applying config and git fallbacks. */
69
+ function resolveIdentity(cwd, config) {
70
+ const identity = readIdentityFile(cwd);
56
71
 
57
72
  // Fallback: project name from harness.config.json
58
73
  if (!identity.project && typeof config?.name === 'string') {
@@ -61,19 +76,23 @@ function resolveConsent(cwd) {
61
76
 
62
77
  // Fallback: alias from git config user.name
63
78
  if (!identity.alias) {
64
- try {
65
- const gitName = execFileSync('git', ['config', 'user.name'], {
66
- cwd,
67
- encoding: 'utf-8',
68
- timeout: 2000,
69
- stdio: ['pipe', 'pipe', 'pipe'],
70
- }).trim();
71
- if (gitName) identity.alias = gitName;
72
- } catch {
73
- // Git not available or no user.name set
74
- }
79
+ const gitName = resolveGitAlias(cwd);
80
+ if (gitName) identity.alias = gitName;
75
81
  }
76
82
 
83
+ return identity;
84
+ }
85
+
86
+ function resolveConsent(cwd) {
87
+ if (process.env.DO_NOT_TRACK === '1') return { allowed: false };
88
+ if (process.env.HARNESS_TELEMETRY_OPTOUT === '1') return { allowed: false };
89
+
90
+ const config = readJsonSafe(join(cwd, 'harness.config.json'));
91
+ if (config?.telemetry?.enabled === false) return { allowed: false };
92
+
93
+ const installId = getOrCreateInstallId(cwd);
94
+ const identity = resolveIdentity(cwd, config);
95
+
77
96
  return { allowed: true, installId, identity };
78
97
  }
79
98