@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.
- package/dist/{agents-md-GOFDOUEI.js → agents-md-OTKZE5HH.js} +4 -4
- package/dist/{analyzer-XRCV63KC-CUGBENHK.js → analyzer-XRCV63KC-FALMS3MO.js} +2 -2
- package/dist/{architecture-UJZTBKJ2.js → architecture-3VYDN7MQ.js} +5 -5
- package/dist/{assess-project-S3UOQUQW.js → assess-project-27X2FSZZ.js} +1 -1
- package/dist/bin/harness-mcp.js +15 -15
- package/dist/bin/harness.js +25 -25
- package/dist/{check-phase-gate-SQ2X2Q7T.js → check-phase-gate-IXLFZ6YH.js} +5 -5
- package/dist/{chunk-FXO3EODY.js → chunk-354MCPT3.js} +3 -3
- package/dist/{chunk-OIMJWYWF.js → chunk-3H7JA24B.js} +7 -7
- package/dist/{chunk-WHPZG5TX.js → chunk-3MKQKNY3.js} +6 -6
- package/dist/{chunk-DPG2WNSN.js → chunk-6Y6M3AZA.js} +3 -3
- package/dist/{chunk-JGW4CHIJ.js → chunk-BMNHLKBA.js} +2 -2
- package/dist/{chunk-CFK4RJFP.js → chunk-CTMHHMHQ.js} +15 -3
- package/dist/{chunk-RD75T4XK.js → chunk-D7QXGI6E.js} +2 -2
- package/dist/{chunk-4ITYAEOA.js → chunk-EFWRZHZ7.js} +101 -97
- package/dist/{chunk-V6QYVM6K.js → chunk-IOJGLIEQ.js} +2 -2
- package/dist/{chunk-DU6AUYTA.js → chunk-IXGYIQSR.js} +1 -1
- package/dist/{chunk-T2PQWRRK.js → chunk-JJLOWMGC.js} +2 -2
- package/dist/{chunk-YE7NZ44F.js → chunk-KPBGAB35.js} +2 -2
- package/dist/{chunk-OWJTMHVP.js → chunk-L5UKNGC5.js} +2 -2
- package/dist/{chunk-R5C7ZFON.js → chunk-LHWBQD7F.js} +10 -1
- package/dist/{chunk-KI64IQLS.js → chunk-LISEPDVT.js} +5 -5
- package/dist/{chunk-UJW4POA3.js → chunk-MEIUXTFY.js} +2 -2
- package/dist/{chunk-RPOBWUKV.js → chunk-NZ5AXKRA.js} +1 -1
- package/dist/{chunk-6RXT67Q4.js → chunk-QHFXVVUY.js} +2 -2
- package/dist/{chunk-SQDQMVNE.js → chunk-S2ETJDQJ.js} +6 -6
- package/dist/{chunk-AIFPI3UZ.js → chunk-TM4KFUWP.js} +2 -2
- package/dist/{chunk-PSFJ5YKQ.js → chunk-VGDZAWCJ.js} +335 -177
- package/dist/{chunk-DFWM2SNS.js → chunk-VRA64HYI.js} +2 -2
- package/dist/{chunk-DSXUS6KG.js → chunk-WAFTJFT3.js} +3 -3
- package/dist/{chunk-7BBQ5C7I.js → chunk-XF6RE3JQ.js} +2 -2
- package/dist/{chunk-XC7NR63Q.js → chunk-XNHKXELN.js} +2 -2
- package/dist/{ci-workflow-5WHUEHOF.js → ci-workflow-Y5GBKNZP.js} +4 -4
- package/dist/{dist-SOKOW7BC.js → dist-4CZLCE33.js} +5 -3
- package/dist/{dist-O7MTFDG5.js → dist-S3KCRI7X.js} +3 -1
- package/dist/{docs-AEHPPDKX.js → docs-3MWDPK4S.js} +5 -5
- package/dist/{engine-HYM5SLCD.js → engine-RJXQ5DHK.js} +4 -4
- package/dist/{entropy-6KH5G3NR.js → entropy-OX5LSM4S.js} +5 -5
- package/dist/{feedback-AUP2JK43.js → feedback-OODAP2G2.js} +1 -1
- package/dist/{generate-agent-definitions-OH4RF445.js → generate-agent-definitions-CCREQZEA.js} +5 -5
- package/dist/hooks/adoption-tracker.js +41 -25
- package/dist/hooks/pre-compact-state.js +45 -27
- package/dist/hooks/sentinel-post.js +125 -102
- package/dist/hooks/sentinel-pre.js +70 -53
- package/dist/hooks/telemetry-reporter.js +44 -25
- package/dist/index.d.ts +655 -0
- package/dist/index.js +25 -25
- package/dist/{loader-KWRPA7D5.js → loader-ZICOJBVX.js} +4 -4
- package/dist/{mcp-JTPO5RAK.js → mcp-XX6EIBLL.js} +15 -15
- package/dist/{performance-HW53ZIAF.js → performance-OLZVGMAY.js} +5 -5
- package/dist/{review-pipeline-2R7UBFTG.js → review-pipeline-3QPHMWPC.js} +4 -4
- package/dist/{runtime-BB35PFZY.js → runtime-ZFFIH76Z.js} +4 -4
- package/dist/{security-IDJHGBBH.js → security-7YW3UK2C.js} +1 -1
- package/dist/{state-events-H3GGBKTB.js → state-events-PTWAEGJ2.js} +4 -4
- package/dist/{validate-6SDGP7AH.js → validate-H5SB7X3Z.js} +5 -5
- package/dist/{validate-cross-check-OF3FPM32.js → validate-cross-check-YYVYMSPA.js} +4 -4
- 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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
+
return null;
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if (!raw.trim()) return null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
52
55
|
}
|
|
56
|
+
}
|
|
53
57
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (typeof
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|