@nestpilot/mcp-app 1.0.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.
- package/README.md +350 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +214 -0
- package/dist/cli/export-import.d.ts +6 -0
- package/dist/cli/export-import.js +132 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +168 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +171 -0
- package/dist/host-configs/cowork.json +11 -0
- package/dist/host-configs/goose.yaml +22 -0
- package/dist/host-configs/openclaw-manifest.json +16 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +128 -0
- package/dist/mcp-app.html +155 -0
- package/dist/nestpilot-client.d.ts +44 -0
- package/dist/nestpilot-client.js +160 -0
- package/dist/planner.html +222 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +245 -0
- package/dist/skills/SKILL.md +162 -0
- package/dist/skills/manifest.json +51 -0
- package/dist/skills/tools/activate_plan.md +36 -0
- package/dist/skills/tools/coach.md +59 -0
- package/dist/skills/tools/comprehensive_plan.md +65 -0
- package/dist/skills/tools/create_plan.md +59 -0
- package/dist/skills/tools/create_saved_plan.md +49 -0
- package/dist/skills/tools/delete_plan.md +42 -0
- package/dist/skills/tools/delete_scenario.md +38 -0
- package/dist/skills/tools/generate_proposal.md +63 -0
- package/dist/skills/tools/generate_retirement_report.md +50 -0
- package/dist/skills/tools/get_active_plan.md +44 -0
- package/dist/skills/tools/get_baseline_forecast.md +47 -0
- package/dist/skills/tools/get_plan.md +44 -0
- package/dist/skills/tools/get_plan_components.md +50 -0
- package/dist/skills/tools/get_scenario.md +46 -0
- package/dist/skills/tools/list_plans.md +44 -0
- package/dist/skills/tools/list_scenarios.md +42 -0
- package/dist/skills/tools/medicare-guardian.md +59 -0
- package/dist/skills/tools/nestpilot_run_plan.md +61 -0
- package/dist/skills/tools/optimize_roth_conversion.md +107 -0
- package/dist/skills/tools/optimize_ss_claiming.md +30 -0
- package/dist/skills/tools/rename_plan.md +34 -0
- package/dist/skills/tools/retirement-planner.md +55 -0
- package/dist/skills/tools/run_forecast.md +65 -0
- package/dist/skills/tools/run_saved_forecast.md +52 -0
- package/dist/skills/tools/run_scenario.md +66 -0
- package/dist/skills/tools/save_plan.md +48 -0
- package/dist/skills/tools/save_scenario.md +50 -0
- package/dist/skills/tools/verify_forecast.md +43 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +44 -0
- package/dist/src/contracts/provenance.d.ts +37 -0
- package/dist/src/contracts/provenance.js +71 -0
- package/dist/src/contracts/tool-contract-registry.d.ts +43 -0
- package/dist/src/contracts/tool-contract-registry.js +282 -0
- package/dist/src/local/cloud-compute-client.d.ts +55 -0
- package/dist/src/local/cloud-compute-client.js +135 -0
- package/dist/src/local/encryption.d.ts +24 -0
- package/dist/src/local/encryption.js +105 -0
- package/dist/src/local/keychain.d.ts +41 -0
- package/dist/src/local/keychain.js +236 -0
- package/dist/src/local/local-config.d.ts +34 -0
- package/dist/src/local/local-config.js +61 -0
- package/dist/src/local/local-data-layer.d.ts +20 -0
- package/dist/src/local/local-data-layer.js +15 -0
- package/dist/src/local/local-plan-store.d.ts +66 -0
- package/dist/src/local/local-plan-store.js +195 -0
- package/dist/src/local/pii-scrubber.d.ts +26 -0
- package/dist/src/local/pii-scrubber.js +219 -0
- package/dist/src/policy/policy-engine.d.ts +44 -0
- package/dist/src/policy/policy-engine.js +119 -0
- package/dist/src/rate-limit.d.ts +17 -0
- package/dist/src/rate-limit.js +41 -0
- package/dist/src/security.d.ts +19 -0
- package/dist/src/security.js +118 -0
- package/dist/src/skills/index.d.ts +12 -0
- package/dist/src/skills/index.js +16 -0
- package/dist/src/skills/retirement-pack-v1.d.ts +28 -0
- package/dist/src/skills/retirement-pack-v1.js +295 -0
- package/dist/src/skills/skill-executor.d.ts +65 -0
- package/dist/src/skills/skill-executor.js +174 -0
- package/dist/src/skills/skill-manifest-schema.d.ts +337 -0
- package/dist/src/skills/skill-manifest-schema.js +94 -0
- package/dist/src/skills/skill-registry.d.ts +71 -0
- package/dist/src/skills/skill-registry.js +116 -0
- package/dist/src/telemetry.d.ts +12 -0
- package/dist/src/telemetry.js +59 -0
- package/dist/src/types.d.ts +46 -0
- package/dist/src/types.js +4 -0
- package/dist/tools/agent-tools.d.ts +12 -0
- package/dist/tools/agent-tools.js +141 -0
- package/dist/tools/forecast-management-tools.d.ts +9 -0
- package/dist/tools/forecast-management-tools.js +133 -0
- package/dist/tools/local-plan-tools.d.ts +8 -0
- package/dist/tools/local-plan-tools.js +357 -0
- package/dist/tools/mcp-helpers.d.ts +52 -0
- package/dist/tools/mcp-helpers.js +177 -0
- package/dist/tools/medicare-tools.d.ts +3 -0
- package/dist/tools/medicare-tools.js +162 -0
- package/dist/tools/optimize-roth-tools-test.d.ts +2 -0
- package/dist/tools/optimize-roth-tools-test.js +36 -0
- package/dist/tools/optimize-roth-tools.d.ts +3 -0
- package/dist/tools/optimize-roth-tools.js +818 -0
- package/dist/tools/plan-management-tools.d.ts +3 -0
- package/dist/tools/plan-management-tools.js +196 -0
- package/dist/tools/planning-tools.d.ts +3 -0
- package/dist/tools/planning-tools.js +290 -0
- package/dist/tools/proposal-tools.d.ts +3 -0
- package/dist/tools/proposal-tools.js +428 -0
- package/dist/tools/report-tools.d.ts +3 -0
- package/dist/tools/report-tools.js +245 -0
- package/dist/tools/scenario-management-tools.d.ts +3 -0
- package/dist/tools/scenario-management-tools.js +136 -0
- package/dist/views/verification-packet.html +211 -0
- package/host-configs/cowork.json +11 -0
- package/host-configs/goose.yaml +22 -0
- package/host-configs/openclaw-manifest.json +16 -0
- package/package.json +66 -0
- package/skills/SKILL.md +162 -0
- package/skills/manifest.json +51 -0
- package/skills/tools/activate_plan.md +36 -0
- package/skills/tools/coach.md +59 -0
- package/skills/tools/comprehensive_plan.md +65 -0
- package/skills/tools/create_plan.md +59 -0
- package/skills/tools/create_saved_plan.md +49 -0
- package/skills/tools/delete_plan.md +42 -0
- package/skills/tools/delete_scenario.md +38 -0
- package/skills/tools/generate_proposal.md +63 -0
- package/skills/tools/generate_retirement_report.md +50 -0
- package/skills/tools/get_active_plan.md +44 -0
- package/skills/tools/get_baseline_forecast.md +47 -0
- package/skills/tools/get_plan.md +44 -0
- package/skills/tools/get_plan_components.md +50 -0
- package/skills/tools/get_scenario.md +46 -0
- package/skills/tools/list_plans.md +44 -0
- package/skills/tools/list_scenarios.md +42 -0
- package/skills/tools/medicare-guardian.md +59 -0
- package/skills/tools/nestpilot_run_plan.md +61 -0
- package/skills/tools/optimize_roth_conversion.md +107 -0
- package/skills/tools/optimize_ss_claiming.md +30 -0
- package/skills/tools/rename_plan.md +34 -0
- package/skills/tools/retirement-planner.md +55 -0
- package/skills/tools/run_forecast.md +65 -0
- package/skills/tools/run_saved_forecast.md +52 -0
- package/skills/tools/run_scenario.md +66 -0
- package/skills/tools/save_plan.md +48 -0
- package/skills/tools/save_scenario.md +50 -0
- package/skills/tools/verify_forecast.md +43 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { EncryptionService } from "./encryption.js";
|
|
2
|
+
export interface LocalPlan {
|
|
3
|
+
id: string;
|
|
4
|
+
version: number;
|
|
5
|
+
created: string;
|
|
6
|
+
updated: string;
|
|
7
|
+
plan: Record<string, unknown>;
|
|
8
|
+
forecasts: Record<string, unknown>[];
|
|
9
|
+
scenarios: Record<string, unknown>[];
|
|
10
|
+
metadata: {
|
|
11
|
+
source: "local" | "imported" | "cloud-sync";
|
|
12
|
+
encryptionVersion: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface PlanSummary {
|
|
16
|
+
id: string;
|
|
17
|
+
updated: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class LocalPlanStore {
|
|
21
|
+
private dataDir;
|
|
22
|
+
private encryption;
|
|
23
|
+
private plansPath;
|
|
24
|
+
private forecastsPath;
|
|
25
|
+
private scenariosPath;
|
|
26
|
+
constructor(dataDir: string, encryption: EncryptionService);
|
|
27
|
+
/**
|
|
28
|
+
* Ensures the data directory structure exists.
|
|
29
|
+
*/
|
|
30
|
+
initialize(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new plan and persists it encrypted to disk.
|
|
33
|
+
*/
|
|
34
|
+
create(plan: Record<string, unknown>): Promise<LocalPlan>;
|
|
35
|
+
/**
|
|
36
|
+
* Lists all stored plans with summary information.
|
|
37
|
+
*/
|
|
38
|
+
list(): Promise<PlanSummary[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Retrieves a specific plan by ID.
|
|
41
|
+
*/
|
|
42
|
+
get(planId: string): Promise<LocalPlan>;
|
|
43
|
+
/**
|
|
44
|
+
* Updates an existing plan.
|
|
45
|
+
*/
|
|
46
|
+
save(planId: string, plan: Record<string, unknown>): Promise<LocalPlan>;
|
|
47
|
+
/**
|
|
48
|
+
* Deletes a plan and its associated cached data.
|
|
49
|
+
*/
|
|
50
|
+
delete(planId: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Caches a forecast result for offline access.
|
|
53
|
+
*/
|
|
54
|
+
cacheForecast(planId: string, forecast: Record<string, unknown>): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Retrieves a cached forecast result, or null if none exists.
|
|
57
|
+
*/
|
|
58
|
+
getCachedForecast(planId: string): Promise<Record<string, unknown> | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Caches a scenario result.
|
|
61
|
+
*/
|
|
62
|
+
cacheScenario(planId: string, label: string, scenario: Record<string, unknown>): Promise<void>;
|
|
63
|
+
private writePlan;
|
|
64
|
+
private readPlanFile;
|
|
65
|
+
private buildSummary;
|
|
66
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Plan Store — encrypted file-based CRUD for retirement plans.
|
|
3
|
+
*
|
|
4
|
+
* All plan data lives under `~/.nestpilot/plans/{uuid}.json` and is
|
|
5
|
+
* encrypted at rest using AES-256-GCM. Forecast results are cached
|
|
6
|
+
* under `~/.nestpilot/forecasts/{uuid}.json` for offline access.
|
|
7
|
+
*
|
|
8
|
+
* This module is only active when NESTPILOT_MODE=local.
|
|
9
|
+
*
|
|
10
|
+
* @feature FEAT-0087
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { forecastsDir, plansDir, scenariosDir } from "./local-config.js";
|
|
16
|
+
// ── LocalPlanStore ───────────────────────────────────────────────────────
|
|
17
|
+
export class LocalPlanStore {
|
|
18
|
+
dataDir;
|
|
19
|
+
encryption;
|
|
20
|
+
plansPath;
|
|
21
|
+
forecastsPath;
|
|
22
|
+
scenariosPath;
|
|
23
|
+
constructor(dataDir, encryption) {
|
|
24
|
+
this.dataDir = dataDir;
|
|
25
|
+
this.encryption = encryption;
|
|
26
|
+
this.plansPath = plansDir(dataDir);
|
|
27
|
+
this.forecastsPath = forecastsDir(dataDir);
|
|
28
|
+
this.scenariosPath = scenariosDir(dataDir);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Ensures the data directory structure exists.
|
|
32
|
+
*/
|
|
33
|
+
async initialize() {
|
|
34
|
+
await fs.mkdir(this.plansPath, { recursive: true });
|
|
35
|
+
await fs.mkdir(this.forecastsPath, { recursive: true });
|
|
36
|
+
await fs.mkdir(this.scenariosPath, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates a new plan and persists it encrypted to disk.
|
|
40
|
+
*/
|
|
41
|
+
async create(plan) {
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const localPlan = {
|
|
44
|
+
id: randomUUID(),
|
|
45
|
+
version: 1,
|
|
46
|
+
created: now,
|
|
47
|
+
updated: now,
|
|
48
|
+
plan,
|
|
49
|
+
forecasts: [],
|
|
50
|
+
scenarios: [],
|
|
51
|
+
metadata: {
|
|
52
|
+
source: "local",
|
|
53
|
+
encryptionVersion: 1,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
await this.writePlan(localPlan);
|
|
57
|
+
return localPlan;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Lists all stored plans with summary information.
|
|
61
|
+
*/
|
|
62
|
+
async list() {
|
|
63
|
+
await this.initialize();
|
|
64
|
+
const files = await fs.readdir(this.plansPath);
|
|
65
|
+
const summaries = [];
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (!file.endsWith(".json"))
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const localPlan = await this.readPlanFile(path.join(this.plansPath, file));
|
|
71
|
+
summaries.push({
|
|
72
|
+
id: localPlan.id,
|
|
73
|
+
updated: localPlan.updated,
|
|
74
|
+
summary: this.buildSummary(localPlan),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.warn(`[local-plan-store] Skipping corrupt plan file: ${file}`, e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return summaries.sort((a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime());
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Retrieves a specific plan by ID.
|
|
85
|
+
*/
|
|
86
|
+
async get(planId) {
|
|
87
|
+
const filePath = path.join(this.plansPath, `${planId}.json`);
|
|
88
|
+
try {
|
|
89
|
+
return await this.readPlanFile(filePath);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (e.code === "ENOENT") {
|
|
93
|
+
throw new Error(`Plan not found: ${planId}`);
|
|
94
|
+
}
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Updates an existing plan.
|
|
100
|
+
*/
|
|
101
|
+
async save(planId, plan) {
|
|
102
|
+
const existing = await this.get(planId);
|
|
103
|
+
const updated = {
|
|
104
|
+
...existing,
|
|
105
|
+
plan,
|
|
106
|
+
version: existing.version + 1,
|
|
107
|
+
updated: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
await this.writePlan(updated);
|
|
110
|
+
return updated;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Deletes a plan and its associated cached data.
|
|
114
|
+
*/
|
|
115
|
+
async delete(planId) {
|
|
116
|
+
const planFile = path.join(this.plansPath, `${planId}.json`);
|
|
117
|
+
const forecastFile = path.join(this.forecastsPath, `${planId}.json`);
|
|
118
|
+
const scenarioFile = path.join(this.scenariosPath, `${planId}.json`);
|
|
119
|
+
// Remove plan (required)
|
|
120
|
+
await fs.unlink(planFile).catch((e) => {
|
|
121
|
+
if (e.code !== "ENOENT")
|
|
122
|
+
throw e;
|
|
123
|
+
});
|
|
124
|
+
// Remove cached data (optional — may not exist)
|
|
125
|
+
await fs.unlink(forecastFile).catch(() => { });
|
|
126
|
+
await fs.unlink(scenarioFile).catch(() => { });
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Caches a forecast result for offline access.
|
|
130
|
+
*/
|
|
131
|
+
async cacheForecast(planId, forecast) {
|
|
132
|
+
const data = JSON.stringify({
|
|
133
|
+
planId,
|
|
134
|
+
cached: new Date().toISOString(),
|
|
135
|
+
forecast,
|
|
136
|
+
});
|
|
137
|
+
const encrypted = await this.encryption.encrypt(data);
|
|
138
|
+
await fs.mkdir(this.forecastsPath, { recursive: true });
|
|
139
|
+
await fs.writeFile(path.join(this.forecastsPath, `${planId}.json`), encrypted);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Retrieves a cached forecast result, or null if none exists.
|
|
143
|
+
*/
|
|
144
|
+
async getCachedForecast(planId) {
|
|
145
|
+
try {
|
|
146
|
+
const encrypted = await fs.readFile(path.join(this.forecastsPath, `${planId}.json`));
|
|
147
|
+
const decrypted = await this.encryption.decrypt(encrypted);
|
|
148
|
+
const data = JSON.parse(decrypted);
|
|
149
|
+
return data.forecast;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Caches a scenario result.
|
|
157
|
+
*/
|
|
158
|
+
async cacheScenario(planId, label, scenario) {
|
|
159
|
+
// Read existing scenarios for this plan
|
|
160
|
+
let existing = [];
|
|
161
|
+
try {
|
|
162
|
+
const encrypted = await fs.readFile(path.join(this.scenariosPath, `${planId}.json`));
|
|
163
|
+
const decrypted = await this.encryption.decrypt(encrypted);
|
|
164
|
+
existing = JSON.parse(decrypted);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// No existing scenarios
|
|
168
|
+
}
|
|
169
|
+
existing.push({ label, scenario });
|
|
170
|
+
const data = JSON.stringify(existing);
|
|
171
|
+
const encrypted = await this.encryption.encrypt(data);
|
|
172
|
+
await fs.mkdir(this.scenariosPath, { recursive: true });
|
|
173
|
+
await fs.writeFile(path.join(this.scenariosPath, `${planId}.json`), encrypted);
|
|
174
|
+
}
|
|
175
|
+
// ── Internal ──────────────────────────────────────────────────────────
|
|
176
|
+
async writePlan(localPlan) {
|
|
177
|
+
await this.initialize();
|
|
178
|
+
const data = JSON.stringify(localPlan, null, 2);
|
|
179
|
+
const encrypted = await this.encryption.encrypt(data);
|
|
180
|
+
await fs.writeFile(path.join(this.plansPath, `${localPlan.id}.json`), encrypted);
|
|
181
|
+
}
|
|
182
|
+
async readPlanFile(filePath) {
|
|
183
|
+
const encrypted = await fs.readFile(filePath);
|
|
184
|
+
const decrypted = await this.encryption.decrypt(encrypted);
|
|
185
|
+
return JSON.parse(decrypted);
|
|
186
|
+
}
|
|
187
|
+
buildSummary(plan) {
|
|
188
|
+
const p = plan.plan;
|
|
189
|
+
const age = p.currentAge ?? p.age ?? "?";
|
|
190
|
+
const retireAge = p.retirementAge ?? p.retireAge ?? "?";
|
|
191
|
+
const spending = p.targetMonthlySpending ?? "?";
|
|
192
|
+
const accountCount = Array.isArray(p.accounts) ? p.accounts.length : 0;
|
|
193
|
+
return `Age ${age}, retire at ${retireAge}, $${spending}/mo spending, ${accountCount} account(s)`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PII Scrubber — strips personally identifiable information from plan
|
|
3
|
+
* payloads before they are sent to the NestPilot cloud compute API.
|
|
4
|
+
*
|
|
5
|
+
* The cloud only needs mathematical inputs (ages, dollar amounts, rates,
|
|
6
|
+
* account types). It never sees names, notes, labels, account numbers,
|
|
7
|
+
* institution names, Plaid tokens, email addresses, SSNs, etc.
|
|
8
|
+
*
|
|
9
|
+
* @feature FEAT-0087
|
|
10
|
+
*/
|
|
11
|
+
export interface ScrubResult {
|
|
12
|
+
/** PII-free payload safe for cloud transmission. */
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
/** Fields that were removed during scrubbing. */
|
|
15
|
+
removedFields: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Scrubs PII from a plan payload, keeping only the mathematical fields
|
|
19
|
+
* required for cloud compute.
|
|
20
|
+
*/
|
|
21
|
+
export declare function scrubPII(plan: Record<string, unknown>): ScrubResult;
|
|
22
|
+
/**
|
|
23
|
+
* Deep-scans a payload to ensure no PII has leaked through.
|
|
24
|
+
* Returns `true` if the payload is PII-free.
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateNoPII(payload: Record<string, unknown>): boolean;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PII Scrubber — strips personally identifiable information from plan
|
|
3
|
+
* payloads before they are sent to the NestPilot cloud compute API.
|
|
4
|
+
*
|
|
5
|
+
* The cloud only needs mathematical inputs (ages, dollar amounts, rates,
|
|
6
|
+
* account types). It never sees names, notes, labels, account numbers,
|
|
7
|
+
* institution names, Plaid tokens, email addresses, SSNs, etc.
|
|
8
|
+
*
|
|
9
|
+
* @feature FEAT-0087
|
|
10
|
+
*/
|
|
11
|
+
// ── Allowed field sets ───────────────────────────────────────────────────
|
|
12
|
+
/** Top-level scalar/numeric fields safe for cloud compute. */
|
|
13
|
+
const ALLOWED_TOP_LEVEL = new Set([
|
|
14
|
+
"currentAge",
|
|
15
|
+
"retirementAge",
|
|
16
|
+
"horizonAge",
|
|
17
|
+
"targetMonthlySpending",
|
|
18
|
+
"socialSecurityFRA",
|
|
19
|
+
"socialSecurityClaimAge",
|
|
20
|
+
"safeWithdrawalRate",
|
|
21
|
+
"effectiveTaxRate",
|
|
22
|
+
"inflationRate",
|
|
23
|
+
"displayMode",
|
|
24
|
+
"withdrawalStrategy",
|
|
25
|
+
"filingStatus",
|
|
26
|
+
"planId",
|
|
27
|
+
"schemaVersion",
|
|
28
|
+
// Nested objects that get scrubbed recursively
|
|
29
|
+
"accounts",
|
|
30
|
+
"incomeStreams",
|
|
31
|
+
"spouse",
|
|
32
|
+
"socialSecurity",
|
|
33
|
+
"healthComponents",
|
|
34
|
+
"spendStreams",
|
|
35
|
+
"strategy",
|
|
36
|
+
"assumptions",
|
|
37
|
+
"goals",
|
|
38
|
+
"household",
|
|
39
|
+
"taxProfile",
|
|
40
|
+
// Scenario-specific
|
|
41
|
+
"plan",
|
|
42
|
+
"overrides",
|
|
43
|
+
"label",
|
|
44
|
+
"payload",
|
|
45
|
+
]);
|
|
46
|
+
/** Account object fields safe for cloud compute. */
|
|
47
|
+
const ALLOWED_ACCOUNT_FIELDS = new Set([
|
|
48
|
+
"type",
|
|
49
|
+
"balance",
|
|
50
|
+
"annualContribution",
|
|
51
|
+
"realReturn",
|
|
52
|
+
"employerMatch",
|
|
53
|
+
"employerMatchLimit",
|
|
54
|
+
"enabled",
|
|
55
|
+
"id",
|
|
56
|
+
]);
|
|
57
|
+
/** Income stream fields safe for cloud compute. */
|
|
58
|
+
const ALLOWED_INCOME_FIELDS = new Set([
|
|
59
|
+
"type",
|
|
60
|
+
"amountMonthly",
|
|
61
|
+
"startAge",
|
|
62
|
+
"endAge",
|
|
63
|
+
"enabled",
|
|
64
|
+
"id",
|
|
65
|
+
"cola",
|
|
66
|
+
]);
|
|
67
|
+
/** Spouse object fields safe for cloud compute. */
|
|
68
|
+
const ALLOWED_SPOUSE_FIELDS = new Set([
|
|
69
|
+
"age",
|
|
70
|
+
"retireAge",
|
|
71
|
+
"currentAge",
|
|
72
|
+
"retirementAge",
|
|
73
|
+
"salary",
|
|
74
|
+
"socialSecurity",
|
|
75
|
+
"accounts",
|
|
76
|
+
"incomeStreams",
|
|
77
|
+
]);
|
|
78
|
+
/** Social Security fields safe for cloud compute. */
|
|
79
|
+
const ALLOWED_SS_FIELDS = new Set([
|
|
80
|
+
"fraBenefitMonthly",
|
|
81
|
+
"claimAge",
|
|
82
|
+
"enabled",
|
|
83
|
+
]);
|
|
84
|
+
/** Known enum values — kept as-is during validation. */
|
|
85
|
+
const KNOWN_ENUM_VALUES = new Set([
|
|
86
|
+
// Account types
|
|
87
|
+
"traditional",
|
|
88
|
+
"roth",
|
|
89
|
+
"taxable",
|
|
90
|
+
"hsa",
|
|
91
|
+
"cash",
|
|
92
|
+
// Income types
|
|
93
|
+
"salary",
|
|
94
|
+
"social_security",
|
|
95
|
+
"pension",
|
|
96
|
+
"annuity",
|
|
97
|
+
"rental",
|
|
98
|
+
"other",
|
|
99
|
+
// Filing status
|
|
100
|
+
"single",
|
|
101
|
+
"married_filing_jointly",
|
|
102
|
+
"married_filing_separately",
|
|
103
|
+
"head_of_household",
|
|
104
|
+
// Withdrawal strategy
|
|
105
|
+
"taxable_first",
|
|
106
|
+
"traditional_first",
|
|
107
|
+
// Display mode
|
|
108
|
+
"real",
|
|
109
|
+
"nominal",
|
|
110
|
+
// Stress test
|
|
111
|
+
"EQUITY",
|
|
112
|
+
"ALL",
|
|
113
|
+
"PLAN_START",
|
|
114
|
+
"RETIREMENT",
|
|
115
|
+
"CUSTOM_YEAR",
|
|
116
|
+
"2008_CRISIS",
|
|
117
|
+
"1929_CRASH",
|
|
118
|
+
"DOT_COM_BUST",
|
|
119
|
+
]);
|
|
120
|
+
// ── PII pattern detection ────────────────────────────────────────────────
|
|
121
|
+
const PII_PATTERNS = [
|
|
122
|
+
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, // email
|
|
123
|
+
/\b\d{3}-\d{2}-\d{4}\b/, // SSN
|
|
124
|
+
/\b\d{3}\.\d{2}\.\d{4}\b/, // SSN variant
|
|
125
|
+
/\(\d{3}\)\s?\d{3}-\d{4}/, // phone (xxx) xxx-xxxx
|
|
126
|
+
/\b\d{3}-\d{3}-\d{4}\b/, // phone xxx-xxx-xxxx
|
|
127
|
+
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // credit card
|
|
128
|
+
/\b\d{9,17}\b/, // account numbers (9-17 digits)
|
|
129
|
+
];
|
|
130
|
+
/** Max string length heuristic — names/notes tend to be longer. */
|
|
131
|
+
const MAX_SAFE_STRING_LENGTH = 50;
|
|
132
|
+
// ── Scrubbing logic ──────────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Scrubs PII from a plan payload, keeping only the mathematical fields
|
|
135
|
+
* required for cloud compute.
|
|
136
|
+
*/
|
|
137
|
+
export function scrubPII(plan) {
|
|
138
|
+
const removedFields = [];
|
|
139
|
+
const payload = scrubObject(plan, ALLOWED_TOP_LEVEL, "", removedFields);
|
|
140
|
+
return { payload, removedFields };
|
|
141
|
+
}
|
|
142
|
+
function scrubObject(obj, allowedKeys, parentPath, removed) {
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
145
|
+
const fieldPath = parentPath ? `${parentPath}.${key}` : key;
|
|
146
|
+
if (!allowedKeys.has(key)) {
|
|
147
|
+
removed.push(fieldPath);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (value === null || value === undefined) {
|
|
151
|
+
result[key] = value;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
result[key] = scrubArray(key, value, fieldPath, removed);
|
|
156
|
+
}
|
|
157
|
+
else if (typeof value === "object") {
|
|
158
|
+
result[key] = scrubNestedObject(key, value, fieldPath, removed);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Primitives — keep numbers/booleans, validate strings
|
|
162
|
+
result[key] = value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
function scrubArray(key, arr, parentPath, removed) {
|
|
168
|
+
if (key === "accounts") {
|
|
169
|
+
return arr.map((item, i) => typeof item === "object" && item !== null
|
|
170
|
+
? scrubObject(item, ALLOWED_ACCOUNT_FIELDS, `${parentPath}[${i}]`, removed)
|
|
171
|
+
: item);
|
|
172
|
+
}
|
|
173
|
+
if (key === "incomeStreams") {
|
|
174
|
+
return arr.map((item, i) => typeof item === "object" && item !== null
|
|
175
|
+
? scrubObject(item, ALLOWED_INCOME_FIELDS, `${parentPath}[${i}]`, removed)
|
|
176
|
+
: item);
|
|
177
|
+
}
|
|
178
|
+
// Generic array — pass through (for component arrays, etc.)
|
|
179
|
+
return arr;
|
|
180
|
+
}
|
|
181
|
+
function scrubNestedObject(key, obj, parentPath, removed) {
|
|
182
|
+
if (key === "spouse") {
|
|
183
|
+
return scrubObject(obj, ALLOWED_SPOUSE_FIELDS, parentPath, removed);
|
|
184
|
+
}
|
|
185
|
+
if (key === "socialSecurity") {
|
|
186
|
+
return scrubObject(obj, ALLOWED_SS_FIELDS, parentPath, removed);
|
|
187
|
+
}
|
|
188
|
+
// For nested objects like 'household', 'goals', 'overrides', 'strategy',
|
|
189
|
+
// 'assumptions', 'taxProfile' — recurse with ALLOWED_TOP_LEVEL since
|
|
190
|
+
// these may contain nested plan-like structures
|
|
191
|
+
return scrubObject(obj, ALLOWED_TOP_LEVEL, parentPath, removed);
|
|
192
|
+
}
|
|
193
|
+
// ── Validation ───────────────────────────────────────────────────────────
|
|
194
|
+
/**
|
|
195
|
+
* Deep-scans a payload to ensure no PII has leaked through.
|
|
196
|
+
* Returns `true` if the payload is PII-free.
|
|
197
|
+
*/
|
|
198
|
+
export function validateNoPII(payload) {
|
|
199
|
+
return !containsPII(payload);
|
|
200
|
+
}
|
|
201
|
+
function containsPII(value) {
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
// Allow known enum values
|
|
204
|
+
if (KNOWN_ENUM_VALUES.has(value))
|
|
205
|
+
return false;
|
|
206
|
+
// Flag long strings that aren't enum values
|
|
207
|
+
if (value.length > MAX_SAFE_STRING_LENGTH)
|
|
208
|
+
return true;
|
|
209
|
+
// Check against PII patterns
|
|
210
|
+
return PII_PATTERNS.some((pattern) => pattern.test(value));
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
return value.some(containsPII);
|
|
214
|
+
}
|
|
215
|
+
if (typeof value === "object" && value !== null) {
|
|
216
|
+
return Object.values(value).some(containsPII);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy Engine Adapter — FEAT-0056
|
|
3
|
+
*
|
|
4
|
+
* Evaluates role/tool/action permissions against the policy baseline
|
|
5
|
+
* before tool execution. Returns machine-readable policy decisions
|
|
6
|
+
* with explicit deny reasons.
|
|
7
|
+
*
|
|
8
|
+
* @feature FEAT-0056
|
|
9
|
+
*/
|
|
10
|
+
export type PolicyDecisionOutcome = "allow" | "deny";
|
|
11
|
+
export interface PolicyDecision {
|
|
12
|
+
decisionId: string;
|
|
13
|
+
actorId: string;
|
|
14
|
+
toolName: string;
|
|
15
|
+
decision: PolicyDecisionOutcome;
|
|
16
|
+
reasonCodes: string[];
|
|
17
|
+
evaluatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export type UserRole = "anonymous" | "authenticated" | "advisor" | "admin";
|
|
20
|
+
export interface PolicyContext {
|
|
21
|
+
actorId: string;
|
|
22
|
+
role: UserRole;
|
|
23
|
+
toolName: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const REASON: {
|
|
26
|
+
readonly TOOL_NOT_IN_ALLOWLIST: "TOOL_NOT_IN_ALLOWLIST";
|
|
27
|
+
readonly ROLE_INSUFFICIENT: "ROLE_INSUFFICIENT";
|
|
28
|
+
readonly PRIVILEGED_TOOL_REQUIRES_ADVISOR: "PRIVILEGED_TOOL_REQUIRES_ADVISOR";
|
|
29
|
+
readonly TOOL_UNREGISTERED: "TOOL_UNREGISTERED";
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Evaluates whether the given actor/role may invoke the specified tool.
|
|
33
|
+
* Returns a machine-readable PolicyDecision with explicit deny reasons.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluatePolicy(ctx: PolicyContext): PolicyDecision;
|
|
36
|
+
/**
|
|
37
|
+
* Convenience: evaluates policy and throws if denied.
|
|
38
|
+
* Returns the allow decision for provenance/audit purposes.
|
|
39
|
+
*/
|
|
40
|
+
export declare function requirePolicy(ctx: PolicyContext): PolicyDecision;
|
|
41
|
+
/**
|
|
42
|
+
* Resets the decision counter (for testing only).
|
|
43
|
+
*/
|
|
44
|
+
export declare function _resetDecisionCounter(): void;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy Engine Adapter — FEAT-0056
|
|
3
|
+
*
|
|
4
|
+
* Evaluates role/tool/action permissions against the policy baseline
|
|
5
|
+
* before tool execution. Returns machine-readable policy decisions
|
|
6
|
+
* with explicit deny reasons.
|
|
7
|
+
*
|
|
8
|
+
* @feature FEAT-0056
|
|
9
|
+
*/
|
|
10
|
+
// ── Reason codes ─────────────────────────────────────────────────────────
|
|
11
|
+
export const REASON = {
|
|
12
|
+
TOOL_NOT_IN_ALLOWLIST: "TOOL_NOT_IN_ALLOWLIST",
|
|
13
|
+
ROLE_INSUFFICIENT: "ROLE_INSUFFICIENT",
|
|
14
|
+
PRIVILEGED_TOOL_REQUIRES_ADVISOR: "PRIVILEGED_TOOL_REQUIRES_ADVISOR",
|
|
15
|
+
TOOL_UNREGISTERED: "TOOL_UNREGISTERED",
|
|
16
|
+
};
|
|
17
|
+
// ── Role-based tool allowlists ───────────────────────────────────────────
|
|
18
|
+
const ROLE_ALLOWLISTS = {
|
|
19
|
+
admin: "all",
|
|
20
|
+
advisor: "all",
|
|
21
|
+
authenticated: new Set([
|
|
22
|
+
"create_plan",
|
|
23
|
+
"run_forecast",
|
|
24
|
+
"run_scenario",
|
|
25
|
+
"verify_forecast",
|
|
26
|
+
"coach",
|
|
27
|
+
"medicare-guardian",
|
|
28
|
+
"medicare-analyze",
|
|
29
|
+
"retirement-planner",
|
|
30
|
+
"email-subscribe",
|
|
31
|
+
"optimize_roth_conversion",
|
|
32
|
+
// plan management
|
|
33
|
+
"list_plans",
|
|
34
|
+
"get_plan",
|
|
35
|
+
"get_active_plan",
|
|
36
|
+
"create_saved_plan",
|
|
37
|
+
"save_plan",
|
|
38
|
+
"activate_plan",
|
|
39
|
+
"delete_plan",
|
|
40
|
+
"rename_plan",
|
|
41
|
+
"get_plan_components",
|
|
42
|
+
// forecast management
|
|
43
|
+
"run_saved_forecast",
|
|
44
|
+
"get_baseline_forecast",
|
|
45
|
+
// scenario management
|
|
46
|
+
"save_scenario",
|
|
47
|
+
"list_scenarios",
|
|
48
|
+
"get_scenario",
|
|
49
|
+
"delete_scenario",
|
|
50
|
+
// comprehensive
|
|
51
|
+
"comprehensive_plan",
|
|
52
|
+
]),
|
|
53
|
+
anonymous: new Set([
|
|
54
|
+
"create_plan",
|
|
55
|
+
"run_forecast",
|
|
56
|
+
"coach",
|
|
57
|
+
"medicare-guardian",
|
|
58
|
+
"medicare-analyze",
|
|
59
|
+
"retirement-planner",
|
|
60
|
+
]),
|
|
61
|
+
};
|
|
62
|
+
// Tools that require at least advisor role
|
|
63
|
+
const PRIVILEGED_TOOLS = new Set([
|
|
64
|
+
// Currently none are privileged-only, but the gate is ready.
|
|
65
|
+
// Future: tools that can execute transactions or modify advisor records.
|
|
66
|
+
]);
|
|
67
|
+
// ── Policy evaluation ────────────────────────────────────────────────────
|
|
68
|
+
let decisionCounter = 0;
|
|
69
|
+
function generateDecisionId() {
|
|
70
|
+
decisionCounter += 1;
|
|
71
|
+
return `pd-${Date.now()}-${decisionCounter}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Evaluates whether the given actor/role may invoke the specified tool.
|
|
75
|
+
* Returns a machine-readable PolicyDecision with explicit deny reasons.
|
|
76
|
+
*/
|
|
77
|
+
export function evaluatePolicy(ctx) {
|
|
78
|
+
const reasonCodes = [];
|
|
79
|
+
// Check privileged tool gate
|
|
80
|
+
if (PRIVILEGED_TOOLS.has(ctx.toolName)) {
|
|
81
|
+
if (ctx.role !== "advisor" && ctx.role !== "admin") {
|
|
82
|
+
reasonCodes.push(REASON.PRIVILEGED_TOOL_REQUIRES_ADVISOR);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Check role allowlist
|
|
86
|
+
const allowlist = ROLE_ALLOWLISTS[ctx.role];
|
|
87
|
+
if (allowlist !== "all" && !allowlist.has(ctx.toolName)) {
|
|
88
|
+
reasonCodes.push(REASON.TOOL_NOT_IN_ALLOWLIST);
|
|
89
|
+
}
|
|
90
|
+
const decision = reasonCodes.length === 0 ? "allow" : "deny";
|
|
91
|
+
return {
|
|
92
|
+
decisionId: generateDecisionId(),
|
|
93
|
+
actorId: ctx.actorId,
|
|
94
|
+
toolName: ctx.toolName,
|
|
95
|
+
decision,
|
|
96
|
+
reasonCodes,
|
|
97
|
+
evaluatedAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Convenience: evaluates policy and throws if denied.
|
|
102
|
+
* Returns the allow decision for provenance/audit purposes.
|
|
103
|
+
*/
|
|
104
|
+
export function requirePolicy(ctx) {
|
|
105
|
+
const decision = evaluatePolicy(ctx);
|
|
106
|
+
if (decision.decision === "deny") {
|
|
107
|
+
const err = new Error(`Policy denied: tool '${ctx.toolName}' for role '${ctx.role}' — [${decision.reasonCodes.join(", ")}]`);
|
|
108
|
+
err.policyDecision =
|
|
109
|
+
decision;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
return decision;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resets the decision counter (for testing only).
|
|
116
|
+
*/
|
|
117
|
+
export function _resetDecisionCounter() {
|
|
118
|
+
decisionCounter = 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory rate limiter — sliding window per key.
|
|
3
|
+
* Adapted from mcp-edge.
|
|
4
|
+
*/
|
|
5
|
+
export interface RateLimitDecision {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
remaining: number;
|
|
8
|
+
resetAtMs: number;
|
|
9
|
+
retryAfterSeconds: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class InMemoryRateLimiter {
|
|
12
|
+
private readonly windowMs;
|
|
13
|
+
private readonly maxRequests;
|
|
14
|
+
private readonly buckets;
|
|
15
|
+
constructor(windowMs: number, maxRequests: number);
|
|
16
|
+
check(key: string, nowMs?: number): RateLimitDecision;
|
|
17
|
+
}
|