@fruition/fcp-mcp-server 1.24.0 → 1.25.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/dist/index.js +265 -2
- package/dist/skills-sync.d.ts +11 -0
- package/dist/skills-sync.js +47 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -93,6 +93,7 @@ const TOOL_PERMISSIONS = {
|
|
|
93
93
|
fcp_clone_to_staging: 'super_admin',
|
|
94
94
|
fcp_clone_confirm: 'super_admin',
|
|
95
95
|
fcp_shield_remove_domain: 'super_admin',
|
|
96
|
+
fcp_trusted_ip_remove_range: 'super_admin',
|
|
96
97
|
fcp_backup_delete_pairing: 'super_admin',
|
|
97
98
|
fcp_filesync_cancel_sync: 'super_admin',
|
|
98
99
|
// --- admin+: mutating ops with real-world side effects ---
|
|
@@ -103,6 +104,8 @@ const TOOL_PERMISSIONS = {
|
|
|
103
104
|
fcp_delete_checklist_item: 'admin',
|
|
104
105
|
fcp_shield_add_domain: 'admin',
|
|
105
106
|
fcp_shield_update_domain: 'admin',
|
|
107
|
+
fcp_trusted_ip_add_range: 'admin',
|
|
108
|
+
fcp_trusted_ip_update_range: 'admin',
|
|
106
109
|
fcp_backup_enable: 'admin',
|
|
107
110
|
fcp_backup_trigger: 'admin',
|
|
108
111
|
fcp_backup_check_trigger: 'admin',
|
|
@@ -149,6 +152,8 @@ const TOOL_PERMISSIONS = {
|
|
|
149
152
|
fcp_shield_list_domains: 'viewer',
|
|
150
153
|
fcp_shield_get_domain: 'viewer',
|
|
151
154
|
fcp_shield_get_metrics: 'viewer',
|
|
155
|
+
fcp_trusted_ip_list_ranges: 'viewer',
|
|
156
|
+
fcp_trusted_ip_export: 'viewer',
|
|
152
157
|
fcp_backup_list_sites: 'viewer',
|
|
153
158
|
fcp_backup_get_config: 'viewer',
|
|
154
159
|
fcp_backup_list_eligible: 'viewer',
|
|
@@ -699,6 +704,78 @@ class FCPClient {
|
|
|
699
704
|
});
|
|
700
705
|
}
|
|
701
706
|
// ============================================================================
|
|
707
|
+
// Trusted IP Range Methods (FRUPRI-761)
|
|
708
|
+
// ============================================================================
|
|
709
|
+
// Text-returning sibling of fetch() for endpoints that emit text/plain or
|
|
710
|
+
// text/yaml (export) or an empty 204 body (delete). The base fetch() always
|
|
711
|
+
// calls response.json() and would throw on those.
|
|
712
|
+
async fetchText(path, options = {}, timeoutMs) {
|
|
713
|
+
const url = `${this.baseUrl}${path}`;
|
|
714
|
+
const headers = {
|
|
715
|
+
...(options.headers || {}),
|
|
716
|
+
};
|
|
717
|
+
if (this.token) {
|
|
718
|
+
if (this.token === 'dev_bypass') {
|
|
719
|
+
headers['X-Dev-Bypass'] = 'true';
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
headers['X-API-Key'] = this.token;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const response = await fetch(url, {
|
|
726
|
+
...options,
|
|
727
|
+
headers,
|
|
728
|
+
signal: AbortSignal.timeout(timeoutMs || this.defaultTimeout),
|
|
729
|
+
});
|
|
730
|
+
if (!response.ok) {
|
|
731
|
+
const error = await response.text();
|
|
732
|
+
throw new Error(`FCP API error (${response.status}): ${error}`);
|
|
733
|
+
}
|
|
734
|
+
return response.text();
|
|
735
|
+
}
|
|
736
|
+
async trustedIpListRanges(filters) {
|
|
737
|
+
const params = new URLSearchParams();
|
|
738
|
+
if (filters?.group)
|
|
739
|
+
params.append('group', filters.group);
|
|
740
|
+
if (filters?.include_pending)
|
|
741
|
+
params.append('include_pending', 'true');
|
|
742
|
+
if (filters?.include_expired)
|
|
743
|
+
params.append('include_expired', 'true');
|
|
744
|
+
const qs = params.toString();
|
|
745
|
+
return this.fetch(`/api/infrastructure/trusted-ips/entries${qs ? `?${qs}` : ''}`);
|
|
746
|
+
}
|
|
747
|
+
async trustedIpAddRange(input) {
|
|
748
|
+
return this.fetch('/api/infrastructure/trusted-ips/entries', {
|
|
749
|
+
method: 'POST',
|
|
750
|
+
body: JSON.stringify(input),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async trustedIpUpdateRange(id, updates) {
|
|
754
|
+
return this.fetch(`/api/infrastructure/trusted-ips/entries/${id}`, {
|
|
755
|
+
method: 'PUT',
|
|
756
|
+
body: JSON.stringify(updates),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
async trustedIpRemoveRange(id) {
|
|
760
|
+
await this.fetchText(`/api/infrastructure/trusted-ips/entries/${id}`, {
|
|
761
|
+
method: 'DELETE',
|
|
762
|
+
});
|
|
763
|
+
return { ok: true, id, message: `Removed trusted IP entry ${id}` };
|
|
764
|
+
}
|
|
765
|
+
async trustedIpExport(opts) {
|
|
766
|
+
const params = new URLSearchParams();
|
|
767
|
+
if (opts?.format)
|
|
768
|
+
params.append('format', opts.format);
|
|
769
|
+
if (opts?.group)
|
|
770
|
+
params.append('group', opts.group);
|
|
771
|
+
if (opts?.purpose)
|
|
772
|
+
params.append('purpose', opts.purpose);
|
|
773
|
+
if (opts?.separator)
|
|
774
|
+
params.append('separator', opts.separator);
|
|
775
|
+
const qs = params.toString();
|
|
776
|
+
return this.fetchText(`/api/infrastructure/trusted-ips/export${qs ? `?${qs}` : ''}`);
|
|
777
|
+
}
|
|
778
|
+
// ============================================================================
|
|
702
779
|
// Backup Management Methods
|
|
703
780
|
// ============================================================================
|
|
704
781
|
async backupListSites() {
|
|
@@ -2274,7 +2351,7 @@ const TOOLS = [
|
|
|
2274
2351
|
// Nuclei Security Scanning
|
|
2275
2352
|
{
|
|
2276
2353
|
name: 'fcp_trigger_nuclei_scan',
|
|
2277
|
-
description: 'Trigger a Nuclei security scan for a website.
|
|
2354
|
+
description: 'Trigger a Nuclei security scan for a website. Queues the scan as a pending record in the FCP database for the local scanner to pick up and run; findings are written back to the database. Returns the scan ID for follow-up queries with fcp_get_nuclei_results.',
|
|
2278
2355
|
inputSchema: {
|
|
2279
2356
|
type: 'object',
|
|
2280
2357
|
properties: {
|
|
@@ -2292,7 +2369,7 @@ const TOOLS = [
|
|
|
2292
2369
|
},
|
|
2293
2370
|
templates: {
|
|
2294
2371
|
type: 'string',
|
|
2295
|
-
description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies',
|
|
2372
|
+
description: 'Comma-separated Nuclei template categories (default: cves,exposures,misconfiguration). Options: cves, vulnerabilities, exposures, misconfiguration, technologies, ssl, dns, headless',
|
|
2296
2373
|
},
|
|
2297
2374
|
},
|
|
2298
2375
|
required: ['website_id'],
|
|
@@ -2843,6 +2920,126 @@ const TOOLS = [
|
|
|
2843
2920
|
},
|
|
2844
2921
|
},
|
|
2845
2922
|
// ============================================================================
|
|
2923
|
+
// Trusted IP Range Tools (FRUPRI-761)
|
|
2924
|
+
// ============================================================================
|
|
2925
|
+
{
|
|
2926
|
+
name: 'fcp_trusted_ip_list_ranges',
|
|
2927
|
+
description: 'List trusted IP ranges (CIDR entries). Scope to a group by id or slug, or omit group to list entries for every group (each tagged with group_slug). By default only approved, non-expired entries are returned.',
|
|
2928
|
+
inputSchema: {
|
|
2929
|
+
type: 'object',
|
|
2930
|
+
properties: {
|
|
2931
|
+
group: {
|
|
2932
|
+
type: 'string',
|
|
2933
|
+
description: 'Group id or slug to scope to (optional; omit for all groups)',
|
|
2934
|
+
},
|
|
2935
|
+
include_pending: {
|
|
2936
|
+
type: 'boolean',
|
|
2937
|
+
description: 'Include entries awaiting approval (default false)',
|
|
2938
|
+
},
|
|
2939
|
+
include_expired: {
|
|
2940
|
+
type: 'boolean',
|
|
2941
|
+
description: 'Include entries past their expires_at (default false)',
|
|
2942
|
+
},
|
|
2943
|
+
},
|
|
2944
|
+
},
|
|
2945
|
+
},
|
|
2946
|
+
{
|
|
2947
|
+
name: 'fcp_trusted_ip_add_range',
|
|
2948
|
+
description: 'Add a CIDR to a trusted IP group. If the CIDR overlaps an existing entry the request is rejected with code=overlap unless override_reason (>=10 chars) is supplied to force-insert.',
|
|
2949
|
+
inputSchema: {
|
|
2950
|
+
type: 'object',
|
|
2951
|
+
properties: {
|
|
2952
|
+
group_id: { type: 'number', description: 'Target group id' },
|
|
2953
|
+
cidr: {
|
|
2954
|
+
type: 'string',
|
|
2955
|
+
description: 'CIDR or bare IP (e.g. 73.241.16.0/24 or 73.241.16.5)',
|
|
2956
|
+
},
|
|
2957
|
+
label: { type: 'string', description: 'Human label for the range' },
|
|
2958
|
+
purpose: {
|
|
2959
|
+
type: 'string',
|
|
2960
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
2961
|
+
description: 'Structured purpose',
|
|
2962
|
+
},
|
|
2963
|
+
ticket_ref: { type: 'string', description: 'Associated ticket reference' },
|
|
2964
|
+
expires_at: {
|
|
2965
|
+
type: 'string',
|
|
2966
|
+
description: 'ISO-8601 expiry timestamp, or null for permanent',
|
|
2967
|
+
},
|
|
2968
|
+
override_reason: {
|
|
2969
|
+
type: 'string',
|
|
2970
|
+
description: 'Reason (>=10 chars) to force-insert past an overlap',
|
|
2971
|
+
},
|
|
2972
|
+
},
|
|
2973
|
+
required: ['group_id', 'cidr'],
|
|
2974
|
+
},
|
|
2975
|
+
},
|
|
2976
|
+
{
|
|
2977
|
+
name: 'fcp_trusted_ip_update_range',
|
|
2978
|
+
description: 'Update mutable fields of a trusted IP entry (label, purpose, ticket_ref, expires_at, approval_state). The CIDR itself is immutable; remove and re-add to change it.',
|
|
2979
|
+
inputSchema: {
|
|
2980
|
+
type: 'object',
|
|
2981
|
+
properties: {
|
|
2982
|
+
id: { type: 'number', description: 'Entry id to update' },
|
|
2983
|
+
label: { type: 'string', description: 'New label' },
|
|
2984
|
+
purpose: {
|
|
2985
|
+
type: 'string',
|
|
2986
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
2987
|
+
description: 'New purpose',
|
|
2988
|
+
},
|
|
2989
|
+
ticket_ref: { type: 'string', description: 'New ticket reference' },
|
|
2990
|
+
expires_at: {
|
|
2991
|
+
type: 'string',
|
|
2992
|
+
description: 'New ISO-8601 expiry, or null for permanent',
|
|
2993
|
+
},
|
|
2994
|
+
approval_state: {
|
|
2995
|
+
type: 'string',
|
|
2996
|
+
enum: ['approved', 'pending', 'denied'],
|
|
2997
|
+
description: 'New approval state',
|
|
2998
|
+
},
|
|
2999
|
+
},
|
|
3000
|
+
required: ['id'],
|
|
3001
|
+
},
|
|
3002
|
+
},
|
|
3003
|
+
{
|
|
3004
|
+
name: 'fcp_trusted_ip_remove_range',
|
|
3005
|
+
description: 'Remove a trusted IP entry by id. The next sync run withdraws the CIDR from any live ingress/WAF targets the group propagates to.',
|
|
3006
|
+
inputSchema: {
|
|
3007
|
+
type: 'object',
|
|
3008
|
+
properties: {
|
|
3009
|
+
id: { type: 'number', description: 'Entry id to remove' },
|
|
3010
|
+
},
|
|
3011
|
+
required: ['id'],
|
|
3012
|
+
},
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
name: 'fcp_trusted_ip_export',
|
|
3016
|
+
description: 'Export approved, non-expired CIDRs for a group (or all groups) in a consumable form. format=plain (default) joins CIDRs with separator (use separator=comma for an ingress-nginx whitelist-source-range value); format=json and format=yaml are also supported.',
|
|
3017
|
+
inputSchema: {
|
|
3018
|
+
type: 'object',
|
|
3019
|
+
properties: {
|
|
3020
|
+
format: {
|
|
3021
|
+
type: 'string',
|
|
3022
|
+
enum: ['plain', 'json', 'yaml'],
|
|
3023
|
+
description: 'Output format (default plain)',
|
|
3024
|
+
},
|
|
3025
|
+
group: {
|
|
3026
|
+
type: 'string',
|
|
3027
|
+
description: 'Group id or slug to scope to (optional; omit for all groups)',
|
|
3028
|
+
},
|
|
3029
|
+
purpose: {
|
|
3030
|
+
type: 'string',
|
|
3031
|
+
enum: ['employee-home', 'office', 'vendor', 'ci-runner', 'migration', 'partner', 'other'],
|
|
3032
|
+
description: 'Optional purpose filter',
|
|
3033
|
+
},
|
|
3034
|
+
separator: {
|
|
3035
|
+
type: 'string',
|
|
3036
|
+
enum: ['newline', 'comma', 'space'],
|
|
3037
|
+
description: 'Separator for plain format (default newline)',
|
|
3038
|
+
},
|
|
3039
|
+
},
|
|
3040
|
+
},
|
|
3041
|
+
},
|
|
3042
|
+
// ============================================================================
|
|
2846
3043
|
// Backup Management Tools
|
|
2847
3044
|
// ============================================================================
|
|
2848
3045
|
{
|
|
@@ -4573,6 +4770,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4573
4770
|
};
|
|
4574
4771
|
}
|
|
4575
4772
|
// ============================================================================
|
|
4773
|
+
// Trusted IP Range Handlers (FRUPRI-761)
|
|
4774
|
+
// ============================================================================
|
|
4775
|
+
case 'fcp_trusted_ip_list_ranges': {
|
|
4776
|
+
const { group, include_pending, include_expired } = args;
|
|
4777
|
+
const result = await client.trustedIpListRanges({
|
|
4778
|
+
group,
|
|
4779
|
+
include_pending,
|
|
4780
|
+
include_expired,
|
|
4781
|
+
});
|
|
4782
|
+
return {
|
|
4783
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4784
|
+
};
|
|
4785
|
+
}
|
|
4786
|
+
case 'fcp_trusted_ip_add_range': {
|
|
4787
|
+
const { group_id, cidr, label, purpose, ticket_ref, expires_at, override_reason, } = args;
|
|
4788
|
+
const result = await client.trustedIpAddRange({
|
|
4789
|
+
group_id,
|
|
4790
|
+
cidr,
|
|
4791
|
+
label,
|
|
4792
|
+
purpose,
|
|
4793
|
+
ticket_ref,
|
|
4794
|
+
expires_at,
|
|
4795
|
+
override_reason,
|
|
4796
|
+
});
|
|
4797
|
+
return {
|
|
4798
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4799
|
+
};
|
|
4800
|
+
}
|
|
4801
|
+
case 'fcp_trusted_ip_update_range': {
|
|
4802
|
+
const { id, label, purpose, ticket_ref, expires_at, approval_state } = args;
|
|
4803
|
+
const updates = {};
|
|
4804
|
+
if (label !== undefined)
|
|
4805
|
+
updates.label = label;
|
|
4806
|
+
if (purpose !== undefined)
|
|
4807
|
+
updates.purpose = purpose;
|
|
4808
|
+
if (ticket_ref !== undefined)
|
|
4809
|
+
updates.ticket_ref = ticket_ref;
|
|
4810
|
+
if (expires_at !== undefined)
|
|
4811
|
+
updates.expires_at = expires_at;
|
|
4812
|
+
if (approval_state !== undefined)
|
|
4813
|
+
updates.approval_state = approval_state;
|
|
4814
|
+
const result = await client.trustedIpUpdateRange(id, updates);
|
|
4815
|
+
return {
|
|
4816
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4819
|
+
case 'fcp_trusted_ip_remove_range': {
|
|
4820
|
+
const { id } = args;
|
|
4821
|
+
const result = await client.trustedIpRemoveRange(id);
|
|
4822
|
+
return {
|
|
4823
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
4824
|
+
};
|
|
4825
|
+
}
|
|
4826
|
+
case 'fcp_trusted_ip_export': {
|
|
4827
|
+
const { format, group, purpose, separator } = args;
|
|
4828
|
+
const text = await client.trustedIpExport({
|
|
4829
|
+
format,
|
|
4830
|
+
group,
|
|
4831
|
+
purpose,
|
|
4832
|
+
separator,
|
|
4833
|
+
});
|
|
4834
|
+
return {
|
|
4835
|
+
content: [{ type: 'text', text }],
|
|
4836
|
+
};
|
|
4837
|
+
}
|
|
4838
|
+
// ============================================================================
|
|
4576
4839
|
// Backup Management Handlers
|
|
4577
4840
|
// ============================================================================
|
|
4578
4841
|
case 'fcp_backup_list_sites': {
|
package/dist/skills-sync.d.ts
CHANGED
|
@@ -105,6 +105,17 @@ export declare function discoverLocalSkills(skillsDir: string): Promise<ParsedSk
|
|
|
105
105
|
* Returns null if pushable, or a reason string if not.
|
|
106
106
|
*/
|
|
107
107
|
export declare function pushSkipReason(skill: ParsedSkill): string | null;
|
|
108
|
+
/**
|
|
109
|
+
* Validate a slug that arrives from the *remote* knowledge graph before it is
|
|
110
|
+
* used to build a filesystem path on pull. `node.source_id`/`node.name` are
|
|
111
|
+
* attacker-influenceable (any team member or compromised KG can author a skill
|
|
112
|
+
* node with an arbitrary source_id), so an unchecked slug like
|
|
113
|
+
* `../../../.config/autostart/x` would let `join(skillsDir, slug, ...)` escape
|
|
114
|
+
* the skills directory and write attacker content anywhere the user can write.
|
|
115
|
+
* A safe slug is a single path component: no separators, no `..`, no leading
|
|
116
|
+
* dot/tilde, no control/whitespace. Returns null if safe, else a reason string.
|
|
117
|
+
*/
|
|
118
|
+
export declare function pullSlugReason(slug: string): string | null;
|
|
108
119
|
/**
|
|
109
120
|
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
110
121
|
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
package/dist/skills-sync.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { promises as fs, existsSync, readFileSync, writeFileSync, mkdirSync, } from 'fs';
|
|
26
26
|
import { homedir } from 'os';
|
|
27
|
-
import { join, basename } from 'path';
|
|
27
|
+
import { join, basename, resolve, sep } from 'path';
|
|
28
28
|
import { createHash } from 'crypto';
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Constants
|
|
@@ -268,6 +268,28 @@ export function pushSkipReason(skill) {
|
|
|
268
268
|
}
|
|
269
269
|
return null;
|
|
270
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Validate a slug that arrives from the *remote* knowledge graph before it is
|
|
273
|
+
* used to build a filesystem path on pull. `node.source_id`/`node.name` are
|
|
274
|
+
* attacker-influenceable (any team member or compromised KG can author a skill
|
|
275
|
+
* node with an arbitrary source_id), so an unchecked slug like
|
|
276
|
+
* `../../../.config/autostart/x` would let `join(skillsDir, slug, ...)` escape
|
|
277
|
+
* the skills directory and write attacker content anywhere the user can write.
|
|
278
|
+
* A safe slug is a single path component: no separators, no `..`, no leading
|
|
279
|
+
* dot/tilde, no control/whitespace. Returns null if safe, else a reason string.
|
|
280
|
+
*/
|
|
281
|
+
export function pullSlugReason(slug) {
|
|
282
|
+
if (!slug)
|
|
283
|
+
return 'empty slug';
|
|
284
|
+
// Single, conservative path component. Mirrors the directory names the push
|
|
285
|
+
// side produces (kebab-case skill slugs) and rejects everything else.
|
|
286
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
287
|
+
return 'slug is not a safe single path component';
|
|
288
|
+
}
|
|
289
|
+
if (slug.includes('..'))
|
|
290
|
+
return 'slug contains ".."';
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
271
293
|
/**
|
|
272
294
|
* Detect control characters (other than tab, LF, CR) in the body. The Unroo
|
|
273
295
|
* capture-skill endpoint 500s on null bytes — and they're never legitimate
|
|
@@ -421,13 +443,35 @@ async function pullOnce(ctx, state, result) {
|
|
|
421
443
|
const slug = node.source_id || node.name;
|
|
422
444
|
if (!slug)
|
|
423
445
|
continue;
|
|
446
|
+
// Never let a remote-controlled slug escape the skills directory.
|
|
447
|
+
const unsafeReason = pullSlugReason(slug);
|
|
448
|
+
if (unsafeReason) {
|
|
449
|
+
result.skipped.push({ slug, why: unsafeReason });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
424
452
|
const props = node.properties ?? {};
|
|
425
|
-
|
|
453
|
+
// The capture endpoint receives `bodyMarkdown` from the wire but persists
|
|
454
|
+
// it to kg_nodes.properties as `body_md` (see knowledge-graph.ts upsertSkill).
|
|
455
|
+
// The search endpoint returns properties verbatim, so the pull side reads
|
|
456
|
+
// `body_md`. Accept either for forward-compat in case the storage shape
|
|
457
|
+
// is normalized later.
|
|
458
|
+
const bodyMarkdown = typeof props.body_md === 'string'
|
|
459
|
+
? props.body_md
|
|
460
|
+
: typeof props.bodyMarkdown === 'string'
|
|
461
|
+
? props.bodyMarkdown
|
|
462
|
+
: '';
|
|
426
463
|
if (!bodyMarkdown) {
|
|
427
|
-
result.skipped.push({ slug, why: 'remote has no
|
|
464
|
+
result.skipped.push({ slug, why: 'remote has no body_md' });
|
|
428
465
|
continue;
|
|
429
466
|
}
|
|
430
467
|
const localPath = join(ctx.skillsDir, slug, 'SKILL.md');
|
|
468
|
+
// Defense in depth: even with a validated slug, confirm the resolved path
|
|
469
|
+
// stays inside the skills directory before any mkdir/write.
|
|
470
|
+
const skillsRoot = resolve(ctx.skillsDir);
|
|
471
|
+
if (!resolve(localPath).startsWith(skillsRoot + sep)) {
|
|
472
|
+
result.skipped.push({ slug, why: 'resolved path escapes skills dir' });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
431
475
|
const existed = existsSync(localPath);
|
|
432
476
|
const prev = state.skills[slug] ?? {};
|
|
433
477
|
// Local doesn't exist — straight pull (clean install or new team skill)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruition/fcp-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.0",
|
|
4
4
|
"description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|