agentfences 0.9.0 → 0.9.1

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 CHANGED
@@ -57,7 +57,7 @@ await context({ user_id: "u_123", session_id: "s_9" }, () => research(question))
57
57
  init({ apiKey: process.env.FENCES_API_KEY, endpoint: "https://your-fences-host", environment: "prod", release: "v1.4.2" });
58
58
  ```
59
59
 
60
- The server is authoritative for limits and sees spend across processes. If it can't be reached, limits are enforced locally; pass `failClosed: true` to stop instead. A rejected key throws `FencesAuthError` when the run starts, and a key revoked during a run ends it at its next checkpoint (`fences_unreachable`); a quarantined agent throws `AgentQuarantined` before any of its code runs. A cost that isn't a finite number of zero or more throws instead of being counted. In serverless handlers, `await flush()` before returning so queued decisions and spans are sent.
60
+ The server is authoritative for limits and sees spend across processes. If it can't be reached, limits are enforced locally; pass `failClosed: true` to stop instead. `endpoint` must be an `https://` URL unless the server is on the same machine; `allowHttp: true` permits plain HTTP for a trusted private network (from 0.9.1). A server reply can raise a budget, but can't make a budget set in code unlimited. A rejected key throws `FencesAuthError` when the run starts, and a key revoked during a run ends it at its next checkpoint (`fences_unreachable`); a quarantined agent throws `AgentQuarantined` before any of its code runs. A cost that isn't a finite number of zero or more throws instead of being counted. In serverless handlers, `await flush()` before returning so queued decisions and spans are sent.
61
61
 
62
62
  ## Live control (from 0.4.0)
63
63
 
package/dist/client.js CHANGED
@@ -94,8 +94,9 @@ export class FencesClient {
94
94
  this.breaker.record(true);
95
95
  return { network_error: String(e) };
96
96
  }
97
+ // Redirects aren't followed: one would carry the API key (and the request) to wherever it points
97
98
  const send = () => fetch(this.endpoint + path, {
98
- method: "POST", headers: this.headers, body: json, signal: AbortSignal.timeout(this.timeoutMs),
99
+ method: "POST", headers: this.headers, body: json, signal: AbortSignal.timeout(this.timeoutMs), redirect: "manual",
99
100
  });
100
101
  let resp;
101
102
  try {
@@ -115,6 +116,10 @@ export class FencesClient {
115
116
  }
116
117
  // A 429 on the heartbeat is its own budget, not an outage: it mustn't cut off checkpoints
117
118
  this.breaker.record(resp.status < 500 && (resp.status !== 429 || path === "/api/heartbeat"));
119
+ if (resp.type === "opaqueredirect" || (resp.status >= 300 && resp.status < 400)) { // not followed: treated as no answer
120
+ return { network_error: `Fences answered with a redirect (HTTP ${resp.status} to ${JSON.stringify(resp.headers.get("location") ?? "?")}), ` +
121
+ "which isn't followed; check the endpoint" };
122
+ }
118
123
  const text = await resp.text().catch(() => "");
119
124
  let body = {}, isJson = false;
120
125
  try {
package/dist/core.d.ts CHANGED
@@ -55,6 +55,12 @@ export interface InitOptions {
55
55
  * still send the values, which rules and approvers need; the redact hook can change or drop them.
56
56
  */
57
57
  toolArguments?: ToolArguments;
58
+ /**
59
+ * endpoint must use https://, except on this machine (localhost, 127.0.0.1, ::1): over plain HTTP anyone
60
+ * on the network path could read the API key and rewrite the server's replies, limits included.
61
+ * true permits it, for a trusted private network.
62
+ */
63
+ allowHttp?: boolean;
58
64
  }
59
65
  /** Call once at startup, before any governed function runs. */
60
66
  export declare function init(options?: InitOptions): void;
package/dist/core.js CHANGED
@@ -41,7 +41,9 @@ export function init(options = {}) {
41
41
  throw new Error("apiKey is required unless localOnly is set. Use init({ localOnly: true }) for local usage.");
42
42
  }
43
43
  else {
44
- client = new FencesClient(options.apiKey, options.endpoint ?? "http://localhost:8000");
44
+ const endpoint = options.endpoint ?? "http://localhost:8000";
45
+ checkEndpoint(endpoint, !!options.allowHttp);
46
+ client = new FencesClient(options.apiKey, endpoint);
45
47
  }
46
48
  control.configure(client, { heartbeatS: options.heartbeatS ?? 15, pauseTimeoutS: options.pauseTimeoutS ?? 3600,
47
49
  stopGraceS: options.stopGraceS === undefined ? 30 : options.stopGraceS, onStop: options.onStop, onQuarantine: options.onQuarantine });
@@ -53,6 +55,26 @@ export function _setClient(c) {
53
55
  }
54
56
  /** @internal */
55
57
  export const _client = () => client;
58
+ function checkEndpoint(endpoint, allowHttp) {
59
+ let url;
60
+ try {
61
+ url = new URL(endpoint);
62
+ }
63
+ catch {
64
+ throw new TypeError(`endpoint must be an https:// URL, not ${JSON.stringify(endpoint)}`);
65
+ }
66
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || endpoint.includes("\\")) {
67
+ throw new TypeError(`endpoint must be an https:// URL with no username or backslash, not ${JSON.stringify(endpoint)}`);
68
+ }
69
+ if (url.protocol === "https:")
70
+ return;
71
+ const host = url.hostname; // parsed as fetch will: the host checked is the host connected to
72
+ const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]";
73
+ if (!local && !allowHttp) {
74
+ throw new TypeError(`endpoint ${JSON.stringify(endpoint)} uses plain HTTP: the API key and the server's replies, limits ` +
75
+ "included, would cross the network unencrypted. Use https://, or pass allowHttp: true for a trusted private network.");
76
+ }
77
+ }
56
78
  function requireInit() {
57
79
  if (!localOnly && !client) {
58
80
  throw new Error("Call agentfences.init() before using Fences. For local usage: init({ localOnly: true })");
@@ -116,6 +138,7 @@ function outcome(run, failed, error) {
116
138
  }
117
139
  async function startRun(agentName, limits) {
118
140
  const run = new Run(agentName, limits.budgetUsd ?? null, limits.maxIterations ?? DEFAULTS.maxIterations, limits.maxDurationMs ?? DEFAULTS.maxDurationMs, limits.maxTokens ?? DEFAULTS.maxTokens, events.currentContext());
141
+ run.budgetInCode = limits.budgetUsd != null;
119
142
  if (!client) {
120
143
  if (run.budgetUsd === null)
121
144
  noBudget(agentName, "Set one with governed(fn, { budgetUsd: ... }).");
@@ -133,7 +156,8 @@ async function startRun(agentName, limits) {
133
156
  if (resp.quarantined)
134
157
  throw new AgentQuarantined(agentName, resp.detail);
135
158
  // The server caps limits at the agent's ceilings: enforce the same numbers if it becomes unreachable later
136
- control.adopt(run, resp.limits ?? {}, control.LIMITS, true);
159
+ // A null budget (unlimited) is taken only for a run whose code set none: a reply can't lift a budget from the code
160
+ control.adopt(run, resp.limits ?? {}, control.LIMITS, limits.budgetUsd == null);
137
161
  for (const n of resp.notices ?? []) {
138
162
  events.warnOnce(`${agentName}:${n.code}`, `${n.message ?? ""}${n.url ? ` ${n.url}` : ""}`);
139
163
  }
@@ -243,7 +267,7 @@ function decide(run, resp) {
243
267
  control.apply(run, resp.commands);
244
268
  // The server is authoritative: adopt its totals and (on a breach) its current limits
245
269
  control.adopt(run, resp, control.TOTALS);
246
- control.adopt(run, resp, control.LIMITS, true);
270
+ control.adopt(run, resp, control.LIMITS, !run.budgetInCode);
247
271
  // A fresh breach, or a 409 because the run is still fenced from an earlier one
248
272
  run.lastBreach = resp.ok ? null : (resp.breach ?? run.lastBreach ?? "limit_reached");
249
273
  }
package/dist/run.d.ts CHANGED
@@ -60,6 +60,8 @@ export declare class Run {
60
60
  reservedUsd: number;
61
61
  /** Model calls' worst-case costs (see precheck.ts), released at the next checkpoint once they finish. */
62
62
  holds: Hold[];
63
+ /** governed() set a budget: no server reply can make the run unlimited. */
64
+ budgetInCode: boolean;
63
65
  /** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
64
66
  onBreach: "hold" | null;
65
67
  private readonly abort;
package/dist/run.js CHANGED
@@ -28,6 +28,8 @@ export class Run {
28
28
  reservedUsd = 0;
29
29
  /** Model calls' worst-case costs (see precheck.ts), released at the next checkpoint once they finish. */
30
30
  holds = [];
31
+ /** governed() set a budget: no server reply can make the run unlimited. */
32
+ budgetInCode = false;
31
33
  /** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
32
34
  onBreach = null;
33
35
  abort = new AbortController();
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
2
- export declare const VERSION = "0.9.0";
2
+ export declare const VERSION = "0.9.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
2
- export const VERSION = "0.9.0";
2
+ export const VERSION = "0.9.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfences",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "On-call for AI agents: limits your agents can't raise, cost tracking, a decision trail, and a stop button",
5
5
  "license": "MIT",
6
6
  "author": "Bhavik Sardar",