@owine/unifi-network-mcp 2.14.2 → 3.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -80
  3. package/dist/client.d.ts +13 -1
  4. package/dist/client.js +142 -16
  5. package/dist/config.d.ts +11 -0
  6. package/dist/config.js +102 -3
  7. package/dist/guard.d.ts +46 -0
  8. package/dist/guard.js +171 -0
  9. package/dist/index.js +15 -4
  10. package/dist/instructions.d.ts +9 -0
  11. package/dist/instructions.js +32 -0
  12. package/dist/tls.d.ts +14 -0
  13. package/dist/tls.js +58 -0
  14. package/dist/tools/acl.js +126 -53
  15. package/dist/tools/client-history.js +20 -10
  16. package/dist/tools/clients.js +14 -13
  17. package/dist/tools/devices.js +30 -29
  18. package/dist/tools/dns-policies.js +116 -69
  19. package/dist/tools/firewall.d.ts +2 -1
  20. package/dist/tools/firewall.js +245 -80
  21. package/dist/tools/hotspot.js +35 -28
  22. package/dist/tools/index.d.ts +13 -1
  23. package/dist/tools/index.js +63 -4
  24. package/dist/tools/networks.d.ts +2 -1
  25. package/dist/tools/networks.js +152 -58
  26. package/dist/tools/sites.js +3 -3
  27. package/dist/tools/supporting.js +31 -30
  28. package/dist/tools/switching.js +22 -21
  29. package/dist/tools/system.js +1 -1
  30. package/dist/tools/traffic-matching.d.ts +2 -1
  31. package/dist/tools/traffic-matching.js +31 -28
  32. package/dist/tools/wifi.js +228 -311
  33. package/dist/utils/full-put.d.ts +32 -0
  34. package/dist/utils/full-put.js +74 -0
  35. package/dist/utils/output-schemas.d.ts +77 -9
  36. package/dist/utils/output-schemas.js +106 -54
  37. package/dist/utils/params.d.ts +7 -0
  38. package/dist/utils/params.js +12 -0
  39. package/dist/utils/responses.d.ts +2 -0
  40. package/dist/utils/responses.js +28 -2
  41. package/dist/utils/safety.d.ts +35 -1
  42. package/dist/utils/safety.js +53 -3
  43. package/dist/utils/secrets.d.ts +24 -0
  44. package/dist/utils/secrets.js +92 -0
  45. package/dist/utils/wan-exposure.d.ts +69 -0
  46. package/dist/utils/wan-exposure.js +245 -0
  47. package/dist/write-log.d.ts +52 -0
  48. package/dist/write-log.js +178 -0
  49. package/dist/write-tools.d.ts +27 -0
  50. package/dist/write-tools.js +50 -0
  51. package/package.json +19 -15
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * WiFi broadcasts come back from the API with their passphrases in plaintext
4
+ * (`securityConfiguration.passphrase`, and one per VLAN in
5
+ * `securityConfiguration.presharedKeys[].passphrase` on PPSK SSIDs). None of
6
+ * this is in the published docs. Tools redact these values by default so they
7
+ * do not land in LLM transcripts; callers opt in with `includeSecrets: true`.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.REDACTED = void 0;
11
+ exports.redactSecrets = redactSecrets;
12
+ exports.restoreRedacted = restoreRedacted;
13
+ exports.containsRedacted = containsRedacted;
14
+ exports.REDACTED = "[REDACTED]";
15
+ const SECRET_KEYS = new Set(["passphrase", "password", "secret", "sharedSecret"]);
16
+ /** Deep copy of `data` with every secret-named string value replaced by REDACTED. */
17
+ function redactSecrets(data) {
18
+ return redact(data);
19
+ }
20
+ function redact(value) {
21
+ if (Array.isArray(value))
22
+ return value.map(redact);
23
+ if (value === null || typeof value !== "object")
24
+ return value;
25
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
26
+ key,
27
+ SECRET_KEYS.has(key) && typeof entry === "string" ? exports.REDACTED : redact(entry),
28
+ ]));
29
+ }
30
+ /**
31
+ * Replace each REDACTED placeholder in `value` with the real value found at the
32
+ * same path in `current`. This lets a caller echo back a redacted object from a
33
+ * read tool without overwriting the passphrase with the placeholder text.
34
+ * Throws if a placeholder has no real value to restore (e.g. on create, or when
35
+ * a new PPSK entry is added without its passphrase).
36
+ *
37
+ * Array items are matched by index, which is only safe while the item is
38
+ * otherwise unchanged: a reordered `presharedKeys[]` must not hand one VLAN's
39
+ * passphrase to another. So a placeholder inside an array item is restored only
40
+ * when that item equals the current item at the same index apart from secrets.
41
+ */
42
+ function restoreRedacted(value, current, path = "body") {
43
+ if (value === exports.REDACTED) {
44
+ if (typeof current === "string")
45
+ return current;
46
+ throw new Error(`${path} is "${exports.REDACTED}" with no existing value to keep — pass the real secret, or omit the field`);
47
+ }
48
+ if (Array.isArray(value)) {
49
+ const currentItems = Array.isArray(current) ? current : [];
50
+ return value.map((item, i) => {
51
+ const itemPath = `${path}[${i}]`;
52
+ if (!containsRedacted(item))
53
+ return item;
54
+ if (stableStringify(redact(item)) !== stableStringify(redact(currentItems[i]))) {
55
+ throw new Error(`${itemPath} contains "${exports.REDACTED}" but differs from the existing entry at that position — pass the real secret for changed or reordered entries`);
56
+ }
57
+ return restoreRedacted(item, currentItems[i], itemPath);
58
+ });
59
+ }
60
+ if (value === null || typeof value !== "object")
61
+ return value;
62
+ const currentObject = current !== null && typeof current === "object" && !Array.isArray(current)
63
+ ? current
64
+ : {};
65
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
66
+ key,
67
+ restoreRedacted(entry, currentObject[key], `${path}.${key}`),
68
+ ]));
69
+ }
70
+ function containsRedacted(value) {
71
+ if (value === exports.REDACTED)
72
+ return true;
73
+ if (Array.isArray(value))
74
+ return value.some(containsRedacted);
75
+ if (value === null || typeof value !== "object")
76
+ return false;
77
+ return Object.values(value).some(containsRedacted);
78
+ }
79
+ function stableStringify(value) {
80
+ if (Array.isArray(value))
81
+ return `[${value.map(stableStringify).join(",")}]`;
82
+ // JSON.stringify returns undefined for undefined and functions, whatever
83
+ // its lib.d.ts signature says.
84
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
85
+ if (value === null || typeof value !== "object")
86
+ return JSON.stringify(value) ?? "undefined";
87
+ const entries = Object.entries(value)
88
+ .filter(([, entry]) => entry !== undefined)
89
+ .sort(([a], [b]) => a.localeCompare(b))
90
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
91
+ return `{${entries.join(",")}}`;
92
+ }
@@ -0,0 +1,69 @@
1
+ import type { NetworkClient } from "../client.js";
2
+ /**
3
+ * Hard refusals for writes that would let traffic in from the WAN. Unlike
4
+ * confirm, which the model can pass after asking (or after being talked into
5
+ * it by data it read), these hold whatever the call says: the operator turns
6
+ * them off with UNIFI_NETWORK_ALLOW_WAN_EXPOSURE=true, and then the existing
7
+ * confirm flow applies.
8
+ *
9
+ * Which zone is the WAN: the Integration API marks zones only with
10
+ * metadata.origin, and names can be changed, so a zone is the WAN when its ID
11
+ * is in UNIFI_NETWORK_WAN_ZONE_IDS, or when it is system-defined and named
12
+ * "External". The ID list is the reliable signal; the name is the fallback
13
+ * for operators who have not set it. When the zones cannot be read, or a
14
+ * zone a change depends on is not among them, the write is refused: a check
15
+ * that cannot run must not pass.
16
+ *
17
+ * ACL rules need no check: they are switch-enforced IPV4/MAC rules between
18
+ * LAN hosts and have no zone, so they cannot admit WAN traffic past the
19
+ * gateway firewall. The Integration API has no port-forward endpoint.
20
+ */
21
+ export interface WanPolicy {
22
+ /** UNIFI_NETWORK_ALLOW_WAN_EXPOSURE: skip these refusals. */
23
+ allowExposure: boolean;
24
+ /** UNIFI_NETWORK_WAN_ZONE_IDS, lower-cased. */
25
+ zoneIds: readonly string[];
26
+ }
27
+ export declare const DEFAULT_WAN_POLICY: WanPolicy;
28
+ /** The checks for one site; zones and policies are read at most once per call. */
29
+ export declare class WanExposure {
30
+ private readonly client;
31
+ private readonly wan;
32
+ private readonly siteId;
33
+ private zones?;
34
+ private policies?;
35
+ constructor(client: NetworkClient, wan: WanPolicy, siteId: string);
36
+ /** A firewall policy, as it would be after the write: refused if it ALLOWs traffic from the WAN. */
37
+ firewallPolicy(policy: Record<string, unknown>): Promise<string | undefined>;
38
+ /** A zone create (zoneId undefined) or full update: refused if it changes the WAN zone or takes networks from it. */
39
+ firewallZone(zoneId: string | undefined, networkIds: readonly string[]): Promise<string | undefined>;
40
+ /** A zone delete: refused if it is the WAN zone. */
41
+ zoneDelete(zoneId: string): Promise<string | undefined>;
42
+ /** The zone a network is put in, and on an update the zone it leaves: refused if either is the WAN zone. */
43
+ networkZone(zoneId: string, currentZoneId?: string): Promise<string | undefined>;
44
+ /** A policy reorder: refused for policies that apply to traffic from the WAN. */
45
+ policyOrder(sourceZoneId: string): Promise<string | undefined>;
46
+ /** A traffic matching list update: refused if a policy that ALLOWs traffic from the WAN uses the list. */
47
+ matchingList(listId: string): Promise<string | undefined>;
48
+ /**
49
+ * Refuse a change that puts networks in zone when a policy ALLOWs traffic
50
+ * from the WAN into it: the networks would be reachable from the WAN with no
51
+ * policy changing. A policy's destination trafficFilter may narrow it to
52
+ * hosts outside the networks, but telling that apart takes more of the
53
+ * filter's shape than the docs pin down, so it counts either way.
54
+ */
55
+ private refuseIfWanTarget;
56
+ private isWan;
57
+ private zone;
58
+ private zoneList;
59
+ private policyList;
60
+ private refusal;
61
+ }
62
+ /** The result of a refused write; method and path are the write it would have sent. */
63
+ export declare function wanRefusal(reason: string, method: string, path: string): {
64
+ content: {
65
+ type: "text";
66
+ text: string;
67
+ }[];
68
+ isError: true;
69
+ };
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WanExposure = exports.DEFAULT_WAN_POLICY = void 0;
4
+ exports.wanRefusal = wanRefusal;
5
+ const zod_1 = require("zod");
6
+ const write_log_js_1 = require("../write-log.js");
7
+ const responses_js_1 = require("./responses.js");
8
+ exports.DEFAULT_WAN_POLICY = { allowExposure: false, zoneIds: [] };
9
+ const Zone = zod_1.z.object({
10
+ id: zod_1.z.string(),
11
+ name: zod_1.z.string().optional(),
12
+ networkIds: zod_1.z.array(zod_1.z.string()).optional(),
13
+ metadata: zod_1.z.object({ origin: zod_1.z.string().optional() }).loose().optional(),
14
+ }).loose();
15
+ const Policy = zod_1.z.object({
16
+ id: zod_1.z.string().optional(),
17
+ name: zod_1.z.string().optional(),
18
+ action: zod_1.z.unknown().optional(),
19
+ source: zod_1.z.unknown().optional(),
20
+ destination: zod_1.z.unknown().optional(),
21
+ }).loose();
22
+ const PAGE_LIMIT = 200;
23
+ const MAX_PAGES = 50;
24
+ async function listAll(client, path, item) {
25
+ const page = zod_1.z.object({ data: zod_1.z.array(item), totalCount: zod_1.z.number().optional() }).loose();
26
+ const items = [];
27
+ for (let pages = 0; pages < MAX_PAGES; pages++) {
28
+ const { data, totalCount } = page.parse(await client.get(`${path}?offset=${items.length}&limit=${PAGE_LIMIT}`));
29
+ items.push(...data);
30
+ // Without a totalCount, only an empty page shows the list has ended: a
31
+ // controller may cap pages below PAGE_LIMIT, so a short page proves nothing.
32
+ if (data.length === 0 || (totalCount !== undefined && items.length >= totalCount))
33
+ return items;
34
+ }
35
+ // A list that does not end cannot be checked, which must not pass.
36
+ throw new Error(`the list did not end after ${MAX_PAGES} pages`);
37
+ }
38
+ class Refusal extends Error {
39
+ }
40
+ /** The checks for one site; zones and policies are read at most once per call. */
41
+ class WanExposure {
42
+ client;
43
+ wan;
44
+ siteId;
45
+ zones;
46
+ policies;
47
+ constructor(client, wan, siteId) {
48
+ this.client = client;
49
+ this.wan = wan;
50
+ this.siteId = siteId;
51
+ }
52
+ /** A firewall policy, as it would be after the write: refused if it ALLOWs traffic from the WAN. */
53
+ async firewallPolicy(policy) {
54
+ if (this.wan.allowExposure || !allowsTraffic(policy))
55
+ return undefined;
56
+ return this.refusal(async () => {
57
+ const zoneId = sourceZoneId(policy);
58
+ if (zoneId === undefined) {
59
+ throw new Refusal("the policy ALLOWs traffic but has no source.zoneId, so its source cannot be checked against the WAN zone");
60
+ }
61
+ const zone = await this.zone(zoneId, "its source zone");
62
+ if (this.isWan(zone))
63
+ throw new Refusal(`the policy would ALLOW traffic from the ${label(zone)} zone, which is the WAN`);
64
+ });
65
+ }
66
+ /** A zone create (zoneId undefined) or full update: refused if it changes the WAN zone or takes networks from it. */
67
+ async firewallZone(zoneId, networkIds) {
68
+ if (this.wan.allowExposure || (zoneId === undefined && networkIds.length === 0))
69
+ return undefined;
70
+ return this.refusal(async () => {
71
+ if (zoneId !== undefined) {
72
+ const zone = await this.zone(zoneId, "the zone");
73
+ if (this.isWan(zone))
74
+ throw new Refusal(`the change would modify the ${label(zone)} zone, which is the WAN`);
75
+ }
76
+ const wanNetworks = new Set((await this.zoneList()).filter((zone) => this.isWan(zone))
77
+ .flatMap((zone) => zone.networkIds ?? []).map((id) => id.toLowerCase()));
78
+ const moved = networkIds.filter((id) => wanNetworks.has(id.toLowerCase()));
79
+ if (moved.length > 0)
80
+ throw new Refusal(`the change would move networks out of the WAN zone: ${moved.join(", ")}`);
81
+ // A new zone has no policies yet; an existing one may be open to the WAN.
82
+ if (zoneId !== undefined) {
83
+ const zone = await this.zone(zoneId, "the zone");
84
+ const present = new Set((zone.networkIds ?? []).map((id) => id.toLowerCase()));
85
+ const added = networkIds.filter((id) => !present.has(id.toLowerCase()));
86
+ if (added.length > 0)
87
+ await this.refuseIfWanTarget(zone, `add networks (${added.join(", ")}) to`);
88
+ }
89
+ });
90
+ }
91
+ /** A zone delete: refused if it is the WAN zone. */
92
+ async zoneDelete(zoneId) {
93
+ if (this.wan.allowExposure)
94
+ return undefined;
95
+ return this.refusal(async () => {
96
+ const zone = await this.zone(zoneId, "the zone");
97
+ if (this.isWan(zone))
98
+ throw new Refusal(`the change would delete the ${label(zone)} zone, which is the WAN`);
99
+ });
100
+ }
101
+ /** The zone a network is put in, and on an update the zone it leaves: refused if either is the WAN zone. */
102
+ async networkZone(zoneId, currentZoneId) {
103
+ if (this.wan.allowExposure)
104
+ return undefined;
105
+ return this.refusal(async () => {
106
+ if (currentZoneId !== undefined) {
107
+ const current = await this.zone(currentZoneId, "the network's current zoneId");
108
+ if (this.isWan(current))
109
+ throw new Refusal(`the change would move the network out of the ${label(current)} zone, which is the WAN`);
110
+ }
111
+ const zone = await this.zone(zoneId, "the network's zoneId");
112
+ if (this.isWan(zone))
113
+ throw new Refusal(`the change would put the network in the ${label(zone)} zone, which is the WAN`);
114
+ await this.refuseIfWanTarget(zone, "put the network in");
115
+ });
116
+ }
117
+ /** A policy reorder: refused for policies that apply to traffic from the WAN. */
118
+ async policyOrder(sourceZoneId) {
119
+ if (this.wan.allowExposure)
120
+ return undefined;
121
+ return this.refusal(async () => {
122
+ const zone = await this.zone(sourceZoneId, "the source zone");
123
+ if (this.isWan(zone)) {
124
+ throw new Refusal(`the change would reorder the policies for traffic from the ${label(zone)} zone, which is the WAN, and could put an ALLOW ahead of a BLOCK`);
125
+ }
126
+ });
127
+ }
128
+ /** A traffic matching list update: refused if a policy that ALLOWs traffic from the WAN uses the list. */
129
+ async matchingList(listId) {
130
+ if (this.wan.allowExposure)
131
+ return undefined;
132
+ return this.refusal(async () => {
133
+ // Either lookup failing refuses the write.
134
+ const [zones, policies] = await Promise.all([this.zoneList(), this.policyList()]);
135
+ const user = policies.find((policy) => {
136
+ // IDs are compared without case: the controller may not care about it.
137
+ if (!allowsTraffic(policy) || !JSON.stringify(policy).toLowerCase().includes(JSON.stringify(listId.toLowerCase())))
138
+ return false;
139
+ // A source zone that is not on the list could be the WAN: count it.
140
+ const zone = zones.find((candidate) => sameId(candidate.id, sourceZoneId(policy)));
141
+ return zone === undefined || this.isWan(zone);
142
+ });
143
+ if (user) {
144
+ throw new Refusal(`the list is used by firewall policy ${JSON.stringify(user.name ?? user.id)}, which ALLOWs traffic from the WAN, so changing it changes what the WAN can reach`);
145
+ }
146
+ });
147
+ }
148
+ /**
149
+ * Refuse a change that puts networks in zone when a policy ALLOWs traffic
150
+ * from the WAN into it: the networks would be reachable from the WAN with no
151
+ * policy changing. A policy's destination trafficFilter may narrow it to
152
+ * hosts outside the networks, but telling that apart takes more of the
153
+ * filter's shape than the docs pin down, so it counts either way.
154
+ */
155
+ async refuseIfWanTarget(zone, change) {
156
+ const [zones, policies] = await Promise.all([this.zoneList(), this.policyList()]);
157
+ const exposing = policies.find((policy) => {
158
+ if (!allowsTraffic(policy) || !sameId(zone.id, destinationZoneId(policy)))
159
+ return false;
160
+ // A source zone that is not on the list could be the WAN: count it.
161
+ const source = zones.find((candidate) => sameId(candidate.id, sourceZoneId(policy)));
162
+ return source === undefined || this.isWan(source);
163
+ });
164
+ if (exposing) {
165
+ throw new Refusal(`the change would ${change} the ${label(zone)} zone, which firewall policy ${JSON.stringify(exposing.name ?? exposing.id)} ALLOWs traffic into from the WAN`);
166
+ }
167
+ }
168
+ isWan(zone) {
169
+ return this.wan.zoneIds.includes(zone.id.toLowerCase()) ||
170
+ (zone.metadata?.origin === "SYSTEM_DEFINED" && zone.name?.toLowerCase() === "external");
171
+ }
172
+ async zone(zoneId, what) {
173
+ const zone = (await this.zoneList()).find((candidate) => sameId(candidate.id, zoneId));
174
+ if (zone === undefined)
175
+ throw new Refusal(`${what} (${zoneId}) is not one of this site's firewall zones, so it cannot be checked against the WAN zone`);
176
+ return zone;
177
+ }
178
+ zoneList() {
179
+ this.zones ??= listAll(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
180
+ throw new Refusal(`the firewall zones could not be read to find the WAN zone (${(0, responses_js_1.errorMessage)(err)})`);
181
+ });
182
+ return this.zones;
183
+ }
184
+ policyList() {
185
+ this.policies ??= listAll(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
186
+ throw new Refusal(`the firewall policies that the change depends on could not be read (${(0, responses_js_1.errorMessage)(err)})`);
187
+ });
188
+ return this.policies;
189
+ }
190
+ async refusal(check) {
191
+ try {
192
+ await check();
193
+ return undefined;
194
+ }
195
+ catch (err) {
196
+ if (err instanceof Refusal)
197
+ return err.message;
198
+ return `the WAN check failed (${(0, responses_js_1.errorMessage)(err)})`;
199
+ }
200
+ }
201
+ }
202
+ exports.WanExposure = WanExposure;
203
+ /** Connection states that only answer traffic already let through, so cannot open a new connection. */
204
+ const RETURN_STATES = ["ESTABLISHED", "RELATED"];
205
+ /**
206
+ * Any action but BLOCK or REJECT lets traffic through, including a missing
207
+ * one, unless the policy matches only ESTABLISHED and RELATED connections.
208
+ * A missing, null or empty connectionStateFilter matches every state.
209
+ */
210
+ function allowsTraffic(policy) {
211
+ const action = policy.action;
212
+ const type = action !== null && typeof action === "object" && "type" in action ? action.type : undefined;
213
+ if (type === "BLOCK" || type === "REJECT")
214
+ return false;
215
+ const states = policy.connectionStateFilter;
216
+ return !(Array.isArray(states) && states.length > 0 && states.every((state) => RETURN_STATES.includes(state)));
217
+ }
218
+ function sourceZoneId(policy) {
219
+ const source = policy.source;
220
+ const zoneId = source !== null && typeof source === "object" && "zoneId" in source ? source.zoneId : undefined;
221
+ return typeof zoneId === "string" ? zoneId : undefined;
222
+ }
223
+ function destinationZoneId(policy) {
224
+ const destination = policy.destination;
225
+ const zoneId = destination !== null && typeof destination === "object" && "zoneId" in destination ? destination.zoneId : undefined;
226
+ return typeof zoneId === "string" ? zoneId : undefined;
227
+ }
228
+ /** IDs are compared without case: the controller may not care about it. */
229
+ function sameId(a, b) {
230
+ return a.toLowerCase() === b?.toLowerCase();
231
+ }
232
+ function label(zone) {
233
+ return zone.name ?? zone.id;
234
+ }
235
+ /** The result of a refused write; method and path are the write it would have sent. */
236
+ function wanRefusal(reason, method, path) {
237
+ (0, write_log_js_1.noteRefusal)("wan_exposure", method, `/integration/v1${path}`);
238
+ return {
239
+ content: [{
240
+ type: "text",
241
+ text: `Refused: ${reason}. This server does not make changes that could let traffic in from the WAN, and confirm does not override this; if the change is intended, make it in the UniFi console.`,
242
+ }],
243
+ isError: true,
244
+ };
245
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * UNIFI_NETWORK_WRITE_LOG: one JSON line per write tool call, so an operator
3
+ * can see what an agent changed (or tried to) without trusting its account
4
+ * of it. Lines carry the canonical request path and HTTP status, never the
5
+ * host, the console ID, the API key or a controller response body.
6
+ */
7
+ export type RefusalReason = "request_guard" | "wan_exposure" | "confirmation_required";
8
+ export interface WriteLogEntry {
9
+ ts: string;
10
+ tsLocal: string;
11
+ tool: string;
12
+ args: unknown;
13
+ method: string | null;
14
+ path: string | null;
15
+ dryRun: boolean;
16
+ outcome: "ok" | "dry_run" | "refused" | "error";
17
+ status?: number;
18
+ reason?: RefusalReason;
19
+ durationMs: number;
20
+ }
21
+ /** NetworkClient: a write request passed the guard and is about to be sent. */
22
+ export declare function noteWriteRequest(method: string, path: string): void;
23
+ /** NetworkClient: the controller answered the write request. */
24
+ export declare function noteWriteStatus(status: number): void;
25
+ /** A check refused the call; the first refusal is the one logged. */
26
+ export declare function noteRefusal(reason: RefusalReason, method?: string, path?: string): void;
27
+ /** "2026-10-18T21:04:05.123+02:00": the local time with its offset. */
28
+ export declare function localTimestamp(date: Date): string;
29
+ export declare class WriteLog {
30
+ private readonly sink;
31
+ private readonly apiKey;
32
+ private failed;
33
+ /** sink appends one line; it may throw, and a failure is reported once and never breaks the call. */
34
+ constructor(sink: (line: string) => void, apiKey: string);
35
+ /**
36
+ * Open path for appending, creating it 0600 (and its directory 0700) if
37
+ * needed. Throws for a symlink, anything but a regular file, or a file
38
+ * others can write to, which could be truncated or filled with lies.
39
+ *
40
+ * On Windows, Node reports every writable file as mode 0666 and a link
41
+ * count that says nothing about access, and O_NOFOLLOW does not exist, so
42
+ * only the symlink and regular-file checks apply there: the file's ACL is
43
+ * the operator's to set.
44
+ */
45
+ static open(path: string, apiKey: string): WriteLog;
46
+ /** Run one write tool call and append its line. */
47
+ record<T>(tool: string, input: unknown, run: () => T | Promise<T>): Promise<T>;
48
+ private entry;
49
+ private append;
50
+ }
51
+ /** Open the write log at startup, or exit with a configuration error. */
52
+ export declare function openWriteLog(path: string, apiKey: string): WriteLog;
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WriteLog = void 0;
4
+ exports.noteWriteRequest = noteWriteRequest;
5
+ exports.noteWriteStatus = noteWriteStatus;
6
+ exports.noteRefusal = noteRefusal;
7
+ exports.localTimestamp = localTimestamp;
8
+ exports.openWriteLog = openWriteLog;
9
+ const node_async_hooks_1 = require("node:async_hooks");
10
+ const node_fs_1 = require("node:fs");
11
+ const node_path_1 = require("node:path");
12
+ const responses_js_1 = require("./utils/responses.js");
13
+ const secrets_js_1 = require("./utils/secrets.js");
14
+ const currentCall = new node_async_hooks_1.AsyncLocalStorage();
15
+ const withoutQuery = (path) => path.split("?")[0];
16
+ /** NetworkClient: a write request passed the guard and is about to be sent. */
17
+ function noteWriteRequest(method, path) {
18
+ const call = currentCall.getStore();
19
+ if (call)
20
+ call.request = { method, path: withoutQuery(path) };
21
+ }
22
+ /** NetworkClient: the controller answered the write request. */
23
+ function noteWriteStatus(status) {
24
+ const request = currentCall.getStore()?.request;
25
+ if (request)
26
+ request.status = status;
27
+ }
28
+ /** A check refused the call; the first refusal is the one logged. */
29
+ function noteRefusal(reason, method, path) {
30
+ const call = currentCall.getStore();
31
+ if (call && !call.refusal)
32
+ call.refusal = { reason, method, ...(path === undefined ? {} : { path: withoutQuery(path) }) };
33
+ }
34
+ /**
35
+ * Field names, compared without case, whose value is redacted whatever it
36
+ * is. Names are matched exactly, not by substring, so containers such as
37
+ * presharedKeys keep their network IDs (redactSecrets masks the passphrase
38
+ * in each entry) and fields like keyId stay readable. The list: the names
39
+ * redactSecrets knows from WiFi and RADIUS bodies, their legacy-API x_
40
+ * forms, and the usual names of credentials and key material a firewall
41
+ * policy record (or a mistaken argument) could carry.
42
+ */
43
+ const SECRET_FIELDS = new Set([
44
+ "passphrase", "password", "secret", "sharedSecret", "radiusSecret",
45
+ "x_passphrase", "x_password", "x_secret", "x_shared_secret",
46
+ "apiKey", "api_key", "apiToken", "token", "accessToken", "refreshToken", "authToken",
47
+ "privateKey", "private_key", "presharedKey",
48
+ ].map((name) => name.toLowerCase()));
49
+ function redactArgs(value) {
50
+ if (Array.isArray(value))
51
+ return value.map(redactArgs);
52
+ if (value === null || typeof value !== "object")
53
+ return value;
54
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, SECRET_FIELDS.has(key.toLowerCase()) ? secrets_js_1.REDACTED : redactArgs(entry)]));
55
+ }
56
+ /** "2026-10-18T21:04:05.123+02:00": the local time with its offset. */
57
+ function localTimestamp(date) {
58
+ const pad = (n, width = 2) => String(Math.abs(n)).padStart(width, "0");
59
+ const offset = -date.getTimezoneOffset();
60
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
61
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` +
62
+ `${offset < 0 ? "-" : "+"}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`;
63
+ }
64
+ class WriteLog {
65
+ sink;
66
+ apiKey;
67
+ failed = false;
68
+ /** sink appends one line; it may throw, and a failure is reported once and never breaks the call. */
69
+ constructor(sink, apiKey) {
70
+ this.sink = sink;
71
+ this.apiKey = apiKey;
72
+ }
73
+ /**
74
+ * Open path for appending, creating it 0600 (and its directory 0700) if
75
+ * needed. Throws for a symlink, anything but a regular file, or a file
76
+ * others can write to, which could be truncated or filled with lies.
77
+ *
78
+ * On Windows, Node reports every writable file as mode 0666 and a link
79
+ * count that says nothing about access, and O_NOFOLLOW does not exist, so
80
+ * only the symlink and regular-file checks apply there: the file's ACL is
81
+ * the operator's to set.
82
+ */
83
+ static open(path, apiKey) {
84
+ const windows = process.platform === "win32";
85
+ let existing;
86
+ try {
87
+ existing = (0, node_fs_1.lstatSync)(path);
88
+ }
89
+ catch (err) {
90
+ if (err.code !== "ENOENT")
91
+ throw err;
92
+ }
93
+ if (existing?.isSymbolicLink())
94
+ throw new Error("it is a symlink; point it at the file itself");
95
+ if (existing === undefined)
96
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
97
+ // O_NOFOLLOW closes the gap between the check above and the open.
98
+ const noFollow = windows ? 0 : node_fs_1.constants.O_NOFOLLOW;
99
+ const fd = (0, node_fs_1.openSync)(path, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_APPEND | node_fs_1.constants.O_CREAT | noFollow, 0o600);
100
+ const stats = (0, node_fs_1.fstatSync)(fd);
101
+ const problem = !stats.isFile() ? "it is not a regular file"
102
+ : windows ? undefined
103
+ : stats.nlink > 1 ? "it has other hard links, so lines could land in another file"
104
+ : (stats.mode & 0o022) !== 0 ? `it is group- or world-writable (mode ${(stats.mode & 0o777).toString(8)}); chmod 600 it`
105
+ : undefined;
106
+ if (problem !== undefined) {
107
+ (0, node_fs_1.closeSync)(fd);
108
+ throw new Error(problem);
109
+ }
110
+ return new WriteLog((line) => { (0, node_fs_1.writeSync)(fd, line); }, apiKey);
111
+ }
112
+ /** Run one write tool call and append its line. */
113
+ async record(tool, input, run) {
114
+ const started = new Date();
115
+ const call = {};
116
+ let result;
117
+ try {
118
+ result = await currentCall.run(call, run);
119
+ return result;
120
+ }
121
+ finally {
122
+ this.append(this.entry(tool, input, call, result, started));
123
+ }
124
+ }
125
+ entry(tool, input, call, result, started) {
126
+ const dryRun = input !== null && typeof input === "object" && "dryRun" in input && input.dryRun === true;
127
+ const preview = result?.structuredContent?.dryRun === true ? result.structuredContent.wouldExecute : undefined;
128
+ const base = {
129
+ ts: started.toISOString(),
130
+ tsLocal: localTimestamp(started),
131
+ tool,
132
+ args: redactArgs((0, secrets_js_1.redactSecrets)(input)),
133
+ method: null,
134
+ path: null,
135
+ dryRun,
136
+ };
137
+ const durationMs = Date.now() - started.getTime();
138
+ if (call.refusal) {
139
+ const { reason, method, path } = call.refusal;
140
+ return { ...base, method: method ?? null, path: path ?? null, outcome: "refused", reason, durationMs };
141
+ }
142
+ if (preview) {
143
+ const method = typeof preview.method === "string" ? preview.method : null;
144
+ const path = typeof preview.path === "string" ? withoutQuery(`/integration/v1${preview.path}`) : null;
145
+ return { ...base, method, path, outcome: "dry_run", durationMs };
146
+ }
147
+ const request = call.request;
148
+ const status = request?.status === undefined ? {} : { status: request.status };
149
+ const where = request ? { method: request.method, path: request.path } : {};
150
+ return { ...base, ...where, outcome: result === undefined || result.isError ? "error" : "ok", ...status, durationMs };
151
+ }
152
+ append(entry) {
153
+ try {
154
+ let line = JSON.stringify(entry);
155
+ // Arguments are the model's; if it ever pasted the key into one, keep it out.
156
+ if (this.apiKey.length >= 8)
157
+ line = line.replaceAll(this.apiKey, secrets_js_1.REDACTED);
158
+ this.sink(`${line}\n`);
159
+ }
160
+ catch (err) {
161
+ if (this.failed)
162
+ return;
163
+ this.failed = true;
164
+ console.error(`UNIFI_NETWORK_WRITE_LOG: cannot write the log (${(0, responses_js_1.errorMessage)(err)}); tool calls continue, but are not being logged.`);
165
+ }
166
+ }
167
+ }
168
+ exports.WriteLog = WriteLog;
169
+ /** Open the write log at startup, or exit with a configuration error. */
170
+ function openWriteLog(path, apiKey) {
171
+ try {
172
+ return WriteLog.open(path, apiKey);
173
+ }
174
+ catch (err) {
175
+ console.error(`Configuration error: cannot use UNIFI_NETWORK_WRITE_LOG (${path}): ${(0, responses_js_1.errorMessage)(err)}`);
176
+ return process.exit(1);
177
+ }
178
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every write tool, grouped by the module in src/tools/ that registers it.
3
+ * UNIFI_NETWORK_WRITE_TOOLS names tools or groups from here, and
4
+ * registerAllTools() refuses to register a write tool that is missing, so the
5
+ * list cannot fall behind the code.
6
+ */
7
+ export declare const WRITE_TOOL_GROUPS: {
8
+ readonly devices: readonly ["unifi_adopt_device", "unifi_remove_device", "unifi_restart_device", "unifi_power_cycle_port"];
9
+ readonly clients: readonly ["unifi_authorize_guest", "unifi_unauthorize_guest"];
10
+ readonly networks: readonly ["unifi_create_network", "unifi_update_network", "unifi_delete_network"];
11
+ readonly wifi: readonly ["unifi_create_wifi", "unifi_update_wifi", "unifi_delete_wifi"];
12
+ readonly hotspot: readonly ["unifi_create_voucher", "unifi_delete_voucher", "unifi_bulk_delete_vouchers"];
13
+ readonly firewall: readonly ["unifi_create_firewall_zone", "unifi_update_firewall_zone", "unifi_delete_firewall_zone", "unifi_create_firewall_policy", "unifi_update_firewall_policy", "unifi_patch_firewall_policy", "unifi_delete_firewall_policy", "unifi_reorder_firewall_policies"];
14
+ readonly acl: readonly ["unifi_create_acl_rule", "unifi_update_acl_rule", "unifi_delete_acl_rule", "unifi_reorder_acl_rules"];
15
+ readonly "dns-policies": readonly ["unifi_create_dns_policy", "unifi_update_dns_policy", "unifi_delete_dns_policy"];
16
+ readonly "traffic-matching": readonly ["unifi_create_traffic_matching_list", "unifi_update_traffic_matching_list", "unifi_delete_traffic_matching_list"];
17
+ };
18
+ export declare const WRITE_TOOLS: readonly string[];
19
+ /**
20
+ * The write tools a UNIFI_NETWORK_WRITE_TOOLS value enables, in catalog
21
+ * order: a comma-separated list of tool and group names, or "" / "none" for
22
+ * none. Names that are neither come back in unknown.
23
+ */
24
+ export declare function parseWriteTools(value: string): {
25
+ tools: string[];
26
+ unknown: string[];
27
+ };