@raidiant/notifai 8.0.0 → 9.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 (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Model-visible session bootstrap. It names the installed capability and its
3
+ * authoritative sources without restating any notification policy that the
4
+ * user may have overridden.
5
+ */
6
+ export const SESSION_ACTIVATION_CONTEXT = 'Notifai is active for this session, even when the user did not mention it. ' +
7
+ 'Before beginning task work, use the Notifai skill and run `notifai guidance`; its resolved guidance governs notification decisions for Agent Events. ' +
8
+ 'Missing skill or CLI readiness is handled through `notifai init` and its diagnosis. ' +
9
+ 'For delegated work, the parent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent and do not send independently.';
10
+ /** One lifecycle meaning, encoded for each harness output contract. */
11
+ export function sessionActivationOutput(harness, hookEventName) {
12
+ if (harness === 'cursor') {
13
+ return JSON.stringify({ additional_context: SESSION_ACTIVATION_CONTEXT });
14
+ }
15
+ if (harness === 'claude-code' || harness === 'codex') {
16
+ return JSON.stringify({
17
+ hookSpecificOutput: {
18
+ hookEventName,
19
+ additionalContext: SESSION_ACTIVATION_CONTEXT,
20
+ },
21
+ });
22
+ }
23
+ return undefined;
24
+ }
25
+ /**
26
+ * Cursor currently accepts sessionStart context without reliably delivering it
27
+ * to the model. Its native post-Stop follow-up is the narrow fallback and is
28
+ * claimed once per conversation by the hook-state layer.
29
+ */
30
+ export function cursorStopActivationOutput() {
31
+ return JSON.stringify({
32
+ followup_message: SESSION_ACTIVATION_CONTEXT.replace('Before beginning task work,', 'Before finalizing the Agent Event from the turn that just ended,'),
33
+ });
34
+ }
35
+ //# sourceMappingURL=session-activation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-activation.js","sourceRoot":"","sources":["../src/session-activation.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,0BAA0B,GACrC,6EAA6E;IAC7E,uJAAuJ;IACvJ,sFAAsF;IACtF,gMAAgM,CAAA;AAElM,uEAAuE;AACvE,MAAM,UAAU,uBAAuB,CACrC,OAAgC,EAChC,aAAoE;IAEpE,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;QACzB,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,CAAC,CAAA;IAC3E,CAAC;IACD,IAAI,OAAO,KAAK,aAAa,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;QACrD,OAAO,IAAI,CAAC,SAAS,CAAC;YACpB,kBAAkB,EAAE;gBAClB,aAAa;gBACb,iBAAiB,EAAE,0BAA0B;aAC9C;SACF,CAAC,CAAA;IACJ,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,IAAI,CAAC,SAAS,CAAC;QACpB,gBAAgB,EAAE,0BAA0B,CAAC,OAAO,CAClD,6BAA6B,EAC7B,kEAAkE,CACnE;KACF,CAAC,CAAA;AACJ,CAAC"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The URL trust policy: which network destinations this CLI will touch on
3
+ * input it did not author. `docs/TRUST.md` is the written policy; this module
4
+ * is its enforcement, and the two must change together.
5
+ *
6
+ * The threat is not a hostile user — it is hostile *input* reaching a
7
+ * trusted user's CLI: a cloned repository, a compromised server response, or
8
+ * an agent following text it found in a working tree. Every rule here is
9
+ * therefore fail-closed, and every escape hatch resolves only from
10
+ * configuration layers the User owns — never from a repository.
11
+ */
12
+ /** The one dashboard origin the shipped service uses for pairing approval. */
13
+ export declare const CANONICAL_DASHBOARD_ORIGIN = "https://app.notifai.sh";
14
+ /** Redirect hops a remote image fetch may follow, each re-validated. */
15
+ export declare const MEDIA_REDIRECT_LIMIT = 3;
16
+ /** One address attempt must not hold an agent command indefinitely. */
17
+ export declare const MEDIA_FETCH_TIMEOUT_MS = 15000;
18
+ export type UrlRefusal = {
19
+ ok: false;
20
+ reason: string;
21
+ };
22
+ /**
23
+ * Normalize one configured origin entry to `scheme://host[:port]`, or null
24
+ * when the entry is not a bare http(s) origin. Paths, queries, fragments, and
25
+ * userinfo are rejected rather than stripped: a config entry that carries
26
+ * them says the author expected them to matter, and silently ignoring parts
27
+ * of a trust rule is how allowlists rot.
28
+ */
29
+ export declare function normalizeOrigin(entry: string): string | null;
30
+ /** Whether one IP literal is publicly routable. */
31
+ export declare function isPublicAddress(address: string): boolean;
32
+ export declare function isLoopbackHostname(hostname: string): boolean;
33
+ export interface MediaUrlPolicy {
34
+ /**
35
+ * Extra origins remote media may use beyond public HTTPS — the self-host
36
+ * and intranet escape hatch. Entries are normalized origins from
37
+ * User-owned configuration (`media_origins`); a repository cannot supply
38
+ * them.
39
+ */
40
+ allowOrigins: readonly string[];
41
+ /** Test seam; production resolves with the system resolver. */
42
+ lookup?: (hostname: string) => Promise<string[]>;
43
+ }
44
+ type MediaUrlApproval = {
45
+ ok: true;
46
+ addresses: readonly string[];
47
+ };
48
+ /**
49
+ * Whether one URL is a destination remote media may be fetched from.
50
+ *
51
+ * Default policy: `https` to a host every resolved address of which is
52
+ * publicly routable. A User-allowed origin skips the scheme and address
53
+ * checks — the User said that exact origin is theirs to trust — but nothing
54
+ * skips the userinfo refusal, and every redirect hop is checked again.
55
+ *
56
+ * The approved addresses come back with the verdict rather than being
57
+ * rediscovered later: `fetchMediaUrl` connects to one of exactly these, so a
58
+ * resolver that would answer differently on a second lookup cannot rebind the
59
+ * request onto a private address between the check and the connection.
60
+ */
61
+ export declare function checkMediaUrl(target: URL, policy: MediaUrlPolicy): Promise<MediaUrlApproval | UrlRefusal>;
62
+ /**
63
+ * Fetch a remote image under the media URL policy, following at most
64
+ * `MEDIA_REDIRECT_LIMIT` redirects and re-validating every hop — a public
65
+ * HTTPS URL redirecting into an intranet is the exact attack the per-hop
66
+ * check exists for.
67
+ */
68
+ export declare function fetchMediaUrl(source: string, policy: MediaUrlPolicy, fetchImpl?: typeof fetch): Promise<{
69
+ ok: true;
70
+ response: Response;
71
+ } | UrlRefusal>;
72
+ /**
73
+ * Whether a server-supplied pairing approval URL may be shown and opened.
74
+ *
75
+ * The server chooses `approve_url`, so a compromised or misconfigured one
76
+ * must not be able to drive this machine's browser to an arbitrary
77
+ * destination. Allowed: the canonical dashboard, the exact origin the user
78
+ * pointed this pairing at, loopback for local development, and origins the
79
+ * User allowed (`approve_origins`) for a self-host whose dashboard lives on
80
+ * a different origin than its API.
81
+ */
82
+ export declare function checkApproveUrl(raw: string, baseUrl: string, allowOrigins: readonly string[]): {
83
+ ok: true;
84
+ } | UrlRefusal;
85
+ export {};
@@ -0,0 +1,369 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { BlockList, isIP } from 'node:net';
5
+ import { Readable } from 'node:stream';
6
+ /**
7
+ * The URL trust policy: which network destinations this CLI will touch on
8
+ * input it did not author. `docs/TRUST.md` is the written policy; this module
9
+ * is its enforcement, and the two must change together.
10
+ *
11
+ * The threat is not a hostile user — it is hostile *input* reaching a
12
+ * trusted user's CLI: a cloned repository, a compromised server response, or
13
+ * an agent following text it found in a working tree. Every rule here is
14
+ * therefore fail-closed, and every escape hatch resolves only from
15
+ * configuration layers the User owns — never from a repository.
16
+ */
17
+ /** The one dashboard origin the shipped service uses for pairing approval. */
18
+ export const CANONICAL_DASHBOARD_ORIGIN = 'https://app.notifai.sh';
19
+ /** Redirect hops a remote image fetch may follow, each re-validated. */
20
+ export const MEDIA_REDIRECT_LIMIT = 3;
21
+ /** One address attempt must not hold an agent command indefinitely. */
22
+ export const MEDIA_FETCH_TIMEOUT_MS = 15_000;
23
+ /**
24
+ * Normalize one configured origin entry to `scheme://host[:port]`, or null
25
+ * when the entry is not a bare http(s) origin. Paths, queries, fragments, and
26
+ * userinfo are rejected rather than stripped: a config entry that carries
27
+ * them says the author expected them to matter, and silently ignoring parts
28
+ * of a trust rule is how allowlists rot.
29
+ */
30
+ export function normalizeOrigin(entry) {
31
+ let url;
32
+ try {
33
+ url = new URL(entry);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
39
+ return null;
40
+ if (url.username !== '' || url.password !== '')
41
+ return null;
42
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '')
43
+ return null;
44
+ // `https://*.example` parses, and would then match exactly one impossible
45
+ // host while reading like a wildcard. Refusing it is how someone finds out
46
+ // that origins are exact before they trust one that never matches.
47
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])$/i.test(url.hostname))
48
+ return null;
49
+ return url.origin;
50
+ }
51
+ /**
52
+ * Addresses that are not publicly routable: loopback, RFC 1918, link-local
53
+ * (including cloud metadata at 169.254.169.254), CGNAT, benchmark,
54
+ * documentation, multicast, and reserved space; for IPv6 also unspecified,
55
+ * unique-local, site-local, and discard prefixes. IPv4-mapped IPv6 addresses
56
+ * are classified as the IPv4 address they carry.
57
+ */
58
+ const NON_PUBLIC = new BlockList();
59
+ const PUBLIC_IPV6 = new BlockList();
60
+ PUBLIC_IPV6.addSubnet('2000::', 3, 'ipv6');
61
+ for (const [subnet, prefix] of [
62
+ ['0.0.0.0', 8],
63
+ ['10.0.0.0', 8],
64
+ ['100.64.0.0', 10],
65
+ ['127.0.0.0', 8],
66
+ ['169.254.0.0', 16],
67
+ ['172.16.0.0', 12],
68
+ ['192.0.0.0', 24],
69
+ ['192.0.2.0', 24],
70
+ ['192.88.99.0', 24],
71
+ ['192.168.0.0', 16],
72
+ ['198.18.0.0', 15],
73
+ ['198.51.100.0', 24],
74
+ ['203.0.113.0', 24],
75
+ ['224.0.0.0', 4],
76
+ ['240.0.0.0', 4],
77
+ ]) {
78
+ NON_PUBLIC.addSubnet(subnet, prefix, 'ipv4');
79
+ }
80
+ for (const [subnet, prefix] of [
81
+ ['::', 128],
82
+ ['::1', 128],
83
+ ['64:ff9b::', 96], // NAT64 — fail closed rather than trust the embedded address
84
+ ['64:ff9b:1::', 48],
85
+ ['100::', 64],
86
+ ['2001::', 32], // Teredo embeds an IPv4 destination
87
+ ['2001:2::', 48],
88
+ ['2001:10::', 28],
89
+ ['2001:20::', 28],
90
+ ['2001:db8::', 32],
91
+ ['2002::', 16], // 6to4 embeds an IPv4 address; fail closed
92
+ ['3fff::', 20],
93
+ ['fc00::', 7],
94
+ ['fe80::', 10],
95
+ ['fec0::', 10],
96
+ ['ff00::', 8],
97
+ // Deliberately no `::ffff:0:0/96` rule: Node checks an IPv4 address against
98
+ // IPv6 rules through its own IPv4-mapped form, so that entry would classify
99
+ // every address on the internet as private. `mappedIpv4` handles the mapped
100
+ // spellings instead.
101
+ ]) {
102
+ NON_PUBLIC.addSubnet(subnet, prefix, 'ipv6');
103
+ }
104
+ /**
105
+ * The IPv4 address an IPv4-mapped IPv6 address carries, in either spelling:
106
+ * `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same address, and only the
107
+ * first would survive being classified as IPv6.
108
+ */
109
+ function mappedIpv4(address) {
110
+ const lower = address.toLowerCase();
111
+ if (!lower.startsWith('::ffff:'))
112
+ return null;
113
+ const rest = lower.slice('::ffff:'.length);
114
+ if (isIP(rest) === 4)
115
+ return rest;
116
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
117
+ if (hex === null)
118
+ return null;
119
+ const high = Number.parseInt(hex[1], 16);
120
+ const low = Number.parseInt(hex[2], 16);
121
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
122
+ }
123
+ /** Whether one IP literal is publicly routable. */
124
+ export function isPublicAddress(address) {
125
+ const candidate = mappedIpv4(address) ?? address;
126
+ const family = isIP(candidate);
127
+ if (family === 0)
128
+ return false;
129
+ // Fail closed outside the currently allocated global-unicast block. This
130
+ // catches reserved IPv6 space without depending on an inevitably stale
131
+ // enumeration; explicit exclusions below then carve special-use prefixes
132
+ // out of global unicast itself.
133
+ if (family === 6 && !PUBLIC_IPV6.check(candidate, 'ipv6'))
134
+ return false;
135
+ return !NON_PUBLIC.check(candidate, family === 6 ? 'ipv6' : 'ipv4');
136
+ }
137
+ /** A URL hostname as an address-or-name: IPv6 hosts arrive bracketed. */
138
+ function hostnameAddress(hostname) {
139
+ return hostname.startsWith('[') && hostname.endsWith(']')
140
+ ? hostname.slice(1, -1)
141
+ : hostname;
142
+ }
143
+ export function isLoopbackHostname(hostname) {
144
+ const host = hostnameAddress(hostname).toLowerCase();
145
+ if (host === 'localhost' || host.endsWith('.localhost'))
146
+ return true;
147
+ const family = isIP(host);
148
+ if (family === 4)
149
+ return host.startsWith('127.');
150
+ if (family === 6)
151
+ return host === '::1' || /^::ffff:127\./.test(host);
152
+ return false;
153
+ }
154
+ async function resolveAddresses(hostname, policy) {
155
+ if (policy.lookup !== undefined) {
156
+ try {
157
+ return await policy.lookup(hostname);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ try {
164
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
165
+ return results.map((entry) => entry.address);
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ /**
172
+ * Whether one URL is a destination remote media may be fetched from.
173
+ *
174
+ * Default policy: `https` to a host every resolved address of which is
175
+ * publicly routable. A User-allowed origin skips the scheme and address
176
+ * checks — the User said that exact origin is theirs to trust — but nothing
177
+ * skips the userinfo refusal, and every redirect hop is checked again.
178
+ *
179
+ * The approved addresses come back with the verdict rather than being
180
+ * rediscovered later: `fetchMediaUrl` connects to one of exactly these, so a
181
+ * resolver that would answer differently on a second lookup cannot rebind the
182
+ * request onto a private address between the check and the connection.
183
+ */
184
+ export async function checkMediaUrl(target, policy) {
185
+ if (target.username !== '' || target.password !== '') {
186
+ return { ok: false, reason: 'Image URLs with embedded credentials are never fetched.' };
187
+ }
188
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
189
+ return { ok: false, reason: `Only http(s) image URLs are supported, not ${target.protocol.replace(/:$/, '')}.` };
190
+ }
191
+ const userAllowed = policy.allowOrigins.includes(target.origin);
192
+ const escapeHatch = 'Remote images use public HTTPS; for a self-hosted or intranet image server, allow its exact origin with `notifai config set media_origins <origin>`.';
193
+ if (!userAllowed && target.protocol !== 'https:') {
194
+ return { ok: false, reason: `${target.origin} is http. ${escapeHatch}` };
195
+ }
196
+ const literal = hostnameAddress(target.hostname);
197
+ if (isIP(literal) !== 0) {
198
+ return userAllowed || isPublicAddress(literal)
199
+ ? { ok: true, addresses: [literal] }
200
+ : { ok: false, reason: `${target.hostname} is not a public address. ${escapeHatch}` };
201
+ }
202
+ const addresses = await resolveAddresses(target.hostname, policy);
203
+ if (addresses === null || addresses.length === 0) {
204
+ return { ok: false, reason: `Could not resolve ${target.hostname}.` };
205
+ }
206
+ if (addresses.some((address) => isIP(address) === 0)) {
207
+ return { ok: false, reason: `${target.hostname} resolved to an invalid address.` };
208
+ }
209
+ if (!userAllowed && !addresses.every((address) => isPublicAddress(address))) {
210
+ return { ok: false, reason: `${target.hostname} resolves to a non-public address. ${escapeHatch}` };
211
+ }
212
+ return { ok: true, addresses };
213
+ }
214
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
215
+ function responseHeaders(response) {
216
+ const headers = new Headers();
217
+ for (const [name, value] of Object.entries(response.headers)) {
218
+ if (Array.isArray(value)) {
219
+ for (const entry of value)
220
+ headers.append(name, entry);
221
+ }
222
+ else if (value !== undefined) {
223
+ headers.set(name, value);
224
+ }
225
+ }
226
+ return headers;
227
+ }
228
+ /**
229
+ * Fetch through one already-vetted address while retaining the URL hostname
230
+ * for Host and TLS verification. This closes the DNS-rebinding gap between
231
+ * policy resolution and the connection: the request cannot resolve the name
232
+ * a second time to a private address.
233
+ */
234
+ async function fetchPinned(target, addresses) {
235
+ let lastError = new Error(`No address was available for ${target.hostname}.`);
236
+ for (const address of addresses) {
237
+ try {
238
+ return await new Promise((resolve, reject) => {
239
+ const family = isIP(address);
240
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
241
+ method: 'GET',
242
+ headers: { 'accept-encoding': 'identity' },
243
+ signal: AbortSignal.timeout(MEDIA_FETCH_TIMEOUT_MS),
244
+ // Node asks for every address at once when it is racing address
245
+ // families, and for a single one otherwise. Answering in the wrong
246
+ // shape throws inside the connect path rather than failing the
247
+ // request, so both forms are answered explicitly.
248
+ lookup: (_hostname, options, callback) => {
249
+ if (options.all === true) {
250
+ callback(null, [{ address, family }]);
251
+ return;
252
+ }
253
+ callback(null, address, family);
254
+ },
255
+ }, (incoming) => {
256
+ const status = incoming.statusCode ?? 500;
257
+ const hasBody = ![101, 204, 205, 304].includes(status);
258
+ const body = hasBody
259
+ ? Readable.toWeb(incoming)
260
+ : null;
261
+ resolve(new Response(body, {
262
+ status,
263
+ ...(incoming.statusMessage === undefined
264
+ ? {}
265
+ : { statusText: incoming.statusMessage }),
266
+ headers: responseHeaders(incoming),
267
+ }));
268
+ });
269
+ request.once('error', reject);
270
+ request.end();
271
+ });
272
+ }
273
+ catch (error) {
274
+ lastError = error;
275
+ }
276
+ }
277
+ throw lastError;
278
+ }
279
+ /**
280
+ * Fetch a remote image under the media URL policy, following at most
281
+ * `MEDIA_REDIRECT_LIMIT` redirects and re-validating every hop — a public
282
+ * HTTPS URL redirecting into an intranet is the exact attack the per-hop
283
+ * check exists for.
284
+ */
285
+ export async function fetchMediaUrl(source, policy, fetchImpl) {
286
+ let target;
287
+ try {
288
+ target = new URL(source);
289
+ }
290
+ catch {
291
+ return { ok: false, reason: `"${source}" is not a valid URL.` };
292
+ }
293
+ for (let hop = 0;; hop += 1) {
294
+ const allowed = await checkMediaUrl(target, policy);
295
+ if (!allowed.ok)
296
+ return allowed;
297
+ const response = fetchImpl === undefined
298
+ ? await fetchPinned(target, allowed.addresses)
299
+ : await fetchImpl(target, { redirect: 'manual' });
300
+ if (!REDIRECT_STATUSES.has(response.status))
301
+ return { ok: true, response };
302
+ const location = response.headers.get('location');
303
+ await response.body?.cancel();
304
+ if (location === null) {
305
+ return { ok: false, reason: `${target.origin} redirected without a destination.` };
306
+ }
307
+ if (hop >= MEDIA_REDIRECT_LIMIT) {
308
+ return { ok: false, reason: `${source} redirected more than ${MEDIA_REDIRECT_LIMIT} times.` };
309
+ }
310
+ try {
311
+ target = new URL(location, target);
312
+ }
313
+ catch {
314
+ return { ok: false, reason: `${target.origin} redirected to an invalid URL.` };
315
+ }
316
+ }
317
+ }
318
+ /**
319
+ * Whether a server-supplied pairing approval URL may be shown and opened.
320
+ *
321
+ * The server chooses `approve_url`, so a compromised or misconfigured one
322
+ * must not be able to drive this machine's browser to an arbitrary
323
+ * destination. Allowed: the canonical dashboard, the exact origin the user
324
+ * pointed this pairing at, loopback for local development, and origins the
325
+ * User allowed (`approve_origins`) for a self-host whose dashboard lives on
326
+ * a different origin than its API.
327
+ */
328
+ export function checkApproveUrl(raw, baseUrl, allowOrigins) {
329
+ let target;
330
+ try {
331
+ target = new URL(raw);
332
+ }
333
+ catch {
334
+ return { ok: false, reason: `the server sent an invalid approval URL.` };
335
+ }
336
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
337
+ return {
338
+ ok: false,
339
+ reason: `the server sent a ${target.protocol.replace(/:$/, '')} approval URL; only http(s) is ever opened.`,
340
+ };
341
+ }
342
+ if (target.username !== '' || target.password !== '') {
343
+ return { ok: false, reason: 'the server sent an approval URL with embedded credentials.' };
344
+ }
345
+ if (target.origin === CANONICAL_DASHBOARD_ORIGIN)
346
+ return { ok: true };
347
+ if (isLoopbackHostname(target.hostname))
348
+ return { ok: true };
349
+ let pairingOrigin = null;
350
+ try {
351
+ pairingOrigin = new URL(baseUrl).origin;
352
+ }
353
+ catch {
354
+ pairingOrigin = null;
355
+ }
356
+ if (allowOrigins.includes(target.origin))
357
+ return { ok: true };
358
+ if (pairingOrigin !== null &&
359
+ target.origin === pairingOrigin &&
360
+ target.protocol === 'https:') {
361
+ return { ok: true };
362
+ }
363
+ return {
364
+ ok: false,
365
+ reason: `the server sent an approval URL on ${target.origin}, which is not the Notifai dashboard, ` +
366
+ 'the origin being paired with, loopback, or an origin in `approve_origins`.',
367
+ };
368
+ }
369
+ //# sourceMappingURL=url-policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-policy.js","sourceRoot":"","sources":["../src/url-policy.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "type": "module",
5
5
  "description": "Send native device notifications from agents and local programs",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "CHANGELOG.md"
14
14
  ],
15
15
  "scripts": {
16
- "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json",
16
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json && node ../../scripts/chmod-cli-bin.mjs",
17
17
  "pretest": "pnpm run build",
18
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
19
19
  "test": "node ../../scripts/run-cli-tests.mjs",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.7.0",
24
- "@raidiant/notifai-protocol": "4.1.0",
24
+ "@raidiant/notifai-protocol": "5.0.0",
25
25
  "commander": "^14.0.0",
26
26
  "picocolors": "^1.1.1",
27
27
  "smol-toml": "^1.4.0"
@@ -19,6 +19,12 @@ export interface AtomicWriteOptions {
19
19
  preserveMode?: boolean
20
20
  /** Reject a target owned by another user instead of replacing it. */
21
21
  requireCurrentUserOwner?: boolean
22
+ /**
23
+ * POSIX directory mode applied to the parent. Defaults to 0o700 when
24
+ * `requireCurrentUserOwner` is set so credential and config dirs match the
25
+ * hook-adapter and log directories.
26
+ */
27
+ directoryMode?: number
22
28
  }
23
29
 
24
30
  /**
@@ -34,8 +40,12 @@ export function atomicWriteFileSync(
34
40
  options: AtomicWriteOptions = {},
35
41
  ): void {
36
42
  const directory = path.dirname(file)
37
- mkdirSync(directory, { recursive: true })
38
- const parent = safeDirectory(directory, options.requireCurrentUserOwner ?? false)
43
+ const requireCurrentUserOwner = options.requireCurrentUserOwner ?? false
44
+ const directoryMode = options.directoryMode ?? (requireCurrentUserOwner ? 0o700 : undefined)
45
+ const parent = ensureDirectory(directory, {
46
+ requireCurrentUserOwner,
47
+ ...(directoryMode === undefined ? {} : { mode: directoryMode }),
48
+ })
39
49
  const target = targetMetadata(
40
50
  file,
41
51
  options.mode ?? 0o600,
@@ -69,6 +79,30 @@ export function atomicWriteFileSync(
69
79
  }
70
80
  }
71
81
 
82
+ /**
83
+ * Create (or tighten) a directory that must not be world-readable.
84
+ *
85
+ * `mkdir` mode applies only to a newly created leaf, so an existing 0755
86
+ * credential directory would otherwise stay world-listable. POSIX chmod of
87
+ * the leaf is the matching repair; Windows has no equivalent mode bits.
88
+ */
89
+ export function ensurePrivateDirectory(directory: string): void {
90
+ ensureDirectory(directory, { mode: 0o700, requireCurrentUserOwner: false })
91
+ }
92
+
93
+ function ensureDirectory(
94
+ directory: string,
95
+ options: { mode?: number; requireCurrentUserOwner: boolean },
96
+ ): { dev: number; ino: number } {
97
+ if (options.mode === undefined) mkdirSync(directory, { recursive: true })
98
+ else mkdirSync(directory, { recursive: true, mode: options.mode })
99
+ const parent = safeDirectory(directory, options.requireCurrentUserOwner)
100
+ if (options.mode !== undefined && process.platform !== 'win32') {
101
+ chmodSync(directory, options.mode)
102
+ }
103
+ return parent
104
+ }
105
+
72
106
  /** Persist the published directory entry where the host supports directory fsync. */
73
107
  function syncDirectory(directory: string): void {
74
108
  if (process.platform === 'win32') return
@@ -0,0 +1,38 @@
1
+ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { cliBinReadiness, isExecutablePath, pathNotifaiEntries } from './cli-bin.js'
6
+
7
+ describe('PATH notifai diagnosis', () => {
8
+ it('reports a non-executable PATH binary as a gap', () => {
9
+ if (process.platform === 'win32') return
10
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-gap-'))
11
+ const file = path.join(directory, 'notifai')
12
+ writeFileSync(file, '#!/usr/bin/env node\n')
13
+ chmodSync(file, 0o644)
14
+ expect(isExecutablePath(file, 'darwin')).toBe(false)
15
+ const state = cliBinReadiness({ PATH: directory }, 'darwin')
16
+ expect(state.status).toBe('gap')
17
+ expect(state.detail).toContain(file)
18
+ expect(state.remedy?.command).toBe('npm install -g @raidiant/notifai')
19
+ })
20
+
21
+ it('treats an executable PATH binary as ready', () => {
22
+ if (process.platform === 'win32') return
23
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-ok-'))
24
+ const file = path.join(directory, 'notifai')
25
+ writeFileSync(file, '#!/usr/bin/env node\n')
26
+ chmodSync(file, 0o755)
27
+ expect(pathNotifaiEntries({ PATH: directory }, 'darwin')).toEqual([file])
28
+ expect(cliBinReadiness({ PATH: directory }, 'darwin').status).toBe('ready')
29
+ })
30
+
31
+ it('is ready when PATH has no notifai entry', () => {
32
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-empty-'))
33
+ mkdirSync(directory, { recursive: true })
34
+ const state = cliBinReadiness({ PATH: directory }, process.platform)
35
+ expect(state.status).toBe('ready')
36
+ expect(state.detail).toContain('no notifai binary was on PATH')
37
+ })
38
+ })