cadet-agent 0.20.2 → 0.24.0

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,133 @@
1
+ /**
2
+ * Cadet-Agent redaction.
3
+ *
4
+ * Removes secrets from structured values and bounded output before ledger
5
+ * persistence and before report display. Covers: bearer/basic auth headers, API
6
+ * keys and common provider prefixes, JWTs, private keys/certificates,
7
+ * passwords and password-like keys, connection strings, cloud access keys,
8
+ * npm/GitHub tokens, and secret values nested in arrays or objects.
9
+ *
10
+ * Contract: docs/core/HarnessContract.md §8.
11
+ */
12
+
13
+ export const REDACTED = '[REDACTED]';
14
+
15
+ /** Key names whose *values* are always redacted, case-insensitive, substring match. */
16
+ const SECRET_KEY_PATTERNS = [
17
+ /pass(word|phrase)?/i,
18
+ /secret/i,
19
+ /token/i,
20
+ /api[_-]?key/i,
21
+ /apikey/i,
22
+ /private[_-]?key/i,
23
+ /client[_-]?secret/i,
24
+ /access[_-]?key/i,
25
+ /auth(orization)?/i,
26
+ /credential/i,
27
+ /connection[_-]?string/i,
28
+ /conn[_-]?str/i,
29
+ /^pwd$/i,
30
+ /session[_-]?id/i,
31
+ /cookie/i,
32
+ ];
33
+
34
+ /** Value patterns redacted regardless of key. Applied in order. */
35
+ const VALUE_RULES = [
36
+ // Bearer / Basic auth headers
37
+ { name: 'auth-header', re: /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
38
+ // JWTs (three base64url segments)
39
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g },
40
+ // Private key blocks
41
+ { name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
42
+ { name: 'certificate', re: /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g },
43
+ // Provider-prefixed API keys
44
+ { name: 'openai-key', re: /\bsk-[A-Za-z0-9-]{16,}\b/g },
45
+ { name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9-]{16,}\b/g },
46
+ { name: 'stripe-key', re: /\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}\b/g },
47
+ { name: 'github-token', re: /\b(gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}\b/g },
48
+ { name: 'npm-token', re: /\bnpm_[A-Za-z0-9]{16,}\b/g },
49
+ { name: 'aws-access-key', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g },
50
+ { name: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
51
+ { name: 'slack-token', re: /\bxox[abopr]-[A-Za-z0-9-]{10,}\b/g },
52
+ // Connection strings with embedded credentials
53
+ { name: 'connection-string', re: /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s:@]+@[^\s'"]+/gi },
54
+ // Generic long hex/base64 secrets assigned via `=` or `:`
55
+ { name: 'assigned-secret', re: /\b(secret|password|passwd|pwd|token|apikey|api_key)\s*[=:]\s*["']?([A-Za-z0-9._~+/=-]{12,})["']?/gi, keepPrefix: true },
56
+ ];
57
+
58
+ function isPlainObject(v) {
59
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
60
+ }
61
+
62
+ /** Redact secrets inside a string value. */
63
+ export function redactString(input) {
64
+ if (typeof input !== 'string') return input;
65
+ let out = input;
66
+ for (const rule of VALUE_RULES) {
67
+ if (rule.keepPrefix) {
68
+ out = out.replace(rule.re, (match, key) => `${key}=${REDACTED}`);
69
+ } else {
70
+ out = out.replace(rule.re, REDACTED);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function keyIsSecret(key) {
77
+ return SECRET_KEY_PATTERNS.some((re) => re.test(key));
78
+ }
79
+
80
+ /**
81
+ * Deeply redact a structured value. Arrays and nested objects are traversed.
82
+ * Secret-like keys always have their values replaced; secret-shaped strings are
83
+ * redacted regardless of key.
84
+ */
85
+ export function redact(value, { maxDepth = 12 } = {}) {
86
+ return redactValue(value, 0, maxDepth);
87
+ }
88
+
89
+ function redactValue(value, depth, maxDepth) {
90
+ if (depth > maxDepth) return '[TRUNCATED]';
91
+ if (typeof value === 'string') return redactString(value);
92
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1, maxDepth));
93
+ if (isPlainObject(value)) {
94
+ const out = {};
95
+ for (const [key, v] of Object.entries(value)) {
96
+ if (keyIsSecret(key)) {
97
+ out[key] = v === null || v === undefined ? v : REDACTED;
98
+ } else {
99
+ out[key] = redactValue(v, depth + 1, maxDepth);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ return value;
105
+ }
106
+
107
+ /**
108
+ * Detect whether a value contains anything that would be redacted, without
109
+ * returning the secret. Useful for tests and for flagging suspicious payloads.
110
+ */
111
+ export function containsSecret(value) {
112
+ let found = false;
113
+ const visit = (v) => {
114
+ if (found) return;
115
+ if (typeof v === 'string') {
116
+ if (redactString(v) !== v) found = true;
117
+ } else if (Array.isArray(v)) {
118
+ v.forEach(visit);
119
+ } else if (isPlainObject(v)) {
120
+ for (const [k, child] of Object.entries(v)) {
121
+ if (keyIsSecret(k) && child !== null && child !== undefined && child !== '') { found = true; return; }
122
+ visit(child);
123
+ }
124
+ }
125
+ };
126
+ visit(value);
127
+ return found;
128
+ }
129
+
130
+ /** Bundled regex source for each named category (used by the fixture coverage test). */
131
+ export const REDACTION_CATEGORIES = Object.freeze(
132
+ Object.fromEntries(VALUE_RULES.map((r) => [r.name, r.re.source]))
133
+ );
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cadet-Agent tool routing.
3
+ *
4
+ * Applies the tool-selection decision tree (contract §8) and capability
5
+ * fallbacks. Routing never pretends a live capability exists: when MCP is
6
+ * unavailable the call falls back to static context and CLI verification.
7
+ * Live-editor mutation requires explicit user confirmation.
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
13
+
14
+ export const TOOL_KINDS = Object.freeze(['cli', 'read', 'search', 'mcp', 'write', 'manual']);
15
+
16
+ export const ROUTING_REASONS = Object.freeze({
17
+ verification: 'deterministic CLI for verification (exit-code driven)',
18
+ staticContext: 'repository read/search for static context',
19
+ liveInspection: 'MCP for live Unity inspection or mutation',
20
+ unavailable: 'capability unavailable — falling back to a safe alternative',
21
+ });
22
+
23
+ class RoutingError extends Error {
24
+ constructor(message, detail = {}) {
25
+ super(message);
26
+ this.name = 'RoutingError';
27
+ Object.assign(this, detail);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Detect the capability set. Nothing here contacts the network or the Editor;
33
+ * it only reports what the environment supports.
34
+ */
35
+ export function detectCapabilities({ targetDir = process.cwd(), env = process.env, runner = defaultRunner } = {}) {
36
+ const unity = which('unity', { env, runner });
37
+ const mcpConfigured = existsSync(join(targetDir, '.vscode', 'mcp.json'))
38
+ || existsSync(join(targetDir, '.cursor', 'mcp.json'))
39
+ || existsSync(join(targetDir, '.mcp.json'));
40
+
41
+ return {
42
+ cli: true,
43
+ unityCli: unity.available ? { available: true, path: unity.path, version: unity.version } : { available: false },
44
+ mcp: mcpConfigured ? { available: true, configured: true } : { available: false, configured: false },
45
+ hook: {
46
+ copilot: existsSync(join(targetDir, '.github', 'hooks', 'git-guard.json')),
47
+ note: 'Cursor, Continue, and Claude Code have no native PreToolUse hook',
48
+ },
49
+ tokenTelemetry: { provider: false, source: 'estimate' },
50
+ costTelemetry: { available: false, reason: 'no model rate card configured' },
51
+ };
52
+ }
53
+
54
+ function defaultRunner(cmd, args) {
55
+ return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true, shell: false });
56
+ }
57
+
58
+ function which(cmd, { env, runner }) {
59
+ const probe = process.platform === 'win32' ? 'where' : 'which';
60
+ const res = runner(probe, [cmd]);
61
+ if (res && res.status === 0 && res.stdout) {
62
+ const path = String(res.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
63
+ let version = null;
64
+ try {
65
+ const v = runner(cmd, ['--version']);
66
+ if (v && v.status === 0 && v.stdout) version = String(v.stdout).trim().split(/\r?\n/)[0];
67
+ } catch { /* version is optional */ }
68
+ return { available: true, path, version };
69
+ }
70
+ return { available: false };
71
+ }
72
+
73
+ /**
74
+ * Decide which tool to use for a task. Returns a routing decision with a reason
75
+ * and the fallback that was applied, if any.
76
+ */
77
+ export function routeTask({ task, capabilities = {} } = {}) {
78
+ if (!task || !TOOL_KINDS.includes(task.kind)) {
79
+ throw new RoutingError(`unknown task kind "${task?.kind}"`);
80
+ }
81
+
82
+ const decide = (tool, reason, extra = {}) => ({
83
+ tool,
84
+ kind: task.kind,
85
+ reason,
86
+ fallbackApplied: false,
87
+ ...extra,
88
+ });
89
+
90
+ switch (task.kind) {
91
+ case 'cli':
92
+ case 'manual':
93
+ return decide(task.kind, ROUTING_REASONS.verification);
94
+
95
+ case 'read':
96
+ case 'search':
97
+ return decide(task.kind, ROUTING_REASONS.staticContext);
98
+
99
+ case 'write': {
100
+ // A write to a live Editor is a mutation and needs confirmation.
101
+ if (task.target === 'live-editor') {
102
+ if (!task.userConfirmed) {
103
+ throw new RoutingError('live-editor mutation requires explicit user confirmation', { blocked: true, requiresConfirmation: true });
104
+ }
105
+ if (!capabilities.mcp?.available) {
106
+ return decide('manual', ROUTING_REASONS.unavailable, {
107
+ fallbackApplied: true,
108
+ fallback: 'live-editor mutation requested but MCP is unavailable; ask the user to perform it',
109
+ });
110
+ }
111
+ return decide('mcp', ROUTING_REASONS.liveInspection, { userConfirmed: true });
112
+ }
113
+ return decide('write', 'repository write within the workspace');
114
+ }
115
+
116
+ case 'mcp': {
117
+ if (capabilities.mcp?.available) {
118
+ return decide('mcp', ROUTING_REASONS.liveInspection);
119
+ }
120
+ // Never pretend live inspection occurred.
121
+ const fallback = task.fallback === 'cli' || task.verification
122
+ ? decide('cli', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context + CLI verification' })
123
+ : decide('read', ROUTING_REASONS.unavailable, { fallbackApplied: true, fallback: 'static context only' });
124
+ return { ...fallback, liveInspectionPerformed: false };
125
+ }
126
+
127
+ default:
128
+ throw new RoutingError(`unhandled task kind "${task.kind}"`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Record a tool call. Arguments are sanitized by the caller (see redaction.mjs)
134
+ * before persistence; this function only shapes the span.
135
+ */
136
+ export function toolCallSpan({ tool, args = {}, rationale, startedAt, finishedAt, durationMs, resultClass, outputBytes = 0, artifactPath = null, exitCode = null, retryNumber = 0 }) {
137
+ return {
138
+ kind: 'tool-call',
139
+ tool,
140
+ args,
141
+ reason: rationale,
142
+ startedAt,
143
+ finishedAt,
144
+ durationMs,
145
+ result: resultClass,
146
+ outputBytes,
147
+ artifactPath,
148
+ exitCode,
149
+ retryNumber,
150
+ };
151
+ }
152
+
153
+ export { RoutingError };