@alashi/cli 0.15.0 → 0.16.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.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
- import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
3
+ import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, compilePolicy, describeRule, evaluateAppConfig, evaluatePolicy, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings, permissionsPolicy } from "@alashi/apps-sdk";
4
4
  import semver from "semver";
5
5
  import { execFile } from "node:child_process";
6
6
  import { appendFile, cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
@@ -1584,6 +1584,21 @@ function superviseSession(session, app, registry) {
1584
1584
  ...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
1585
1585
  };
1586
1586
  }
1587
+ /** Runs `onClose` once when the session closes (drops its gateway grants). */
1588
+ function endOnClose(session, onClose) {
1589
+ return {
1590
+ get id() {
1591
+ return session.id;
1592
+ },
1593
+ send: (input) => session.send(input),
1594
+ interrupt: () => session.interrupt(),
1595
+ close: async () => {
1596
+ onClose();
1597
+ await session.close();
1598
+ },
1599
+ ...session.respondToApproval !== void 0 && { respondToApproval: session.respondToApproval.bind(session) }
1600
+ };
1601
+ }
1587
1602
  /**
1588
1603
  * Wraps the host provider so every session an app opens automatically gets
1589
1604
  * its manifest-declared MCP servers, tool allowlist, agents, skills,
@@ -1600,6 +1615,30 @@ function scopeProvider(provider, defaults) {
1600
1615
  ...defaults.mcpServers,
1601
1616
  ...options.mcpServers
1602
1617
  };
1618
+ if (defaults.gateSession) {
1619
+ const { allowedTools: callAllowed, askTools: callAsk, ...rest } = options;
1620
+ const gate = defaults.gateSession({
1621
+ ...callAllowed !== void 0 && { allowedTools: callAllowed },
1622
+ ...callAsk !== void 0 && { askTools: callAsk }
1623
+ });
1624
+ const agents = {
1625
+ ...defaults.agents,
1626
+ ...rest.agents
1627
+ };
1628
+ const skillDirs = [.../* @__PURE__ */ new Set([...defaults.skillDirs, ...rest.skillDirs ?? []])];
1629
+ let session = await provider.createSession({
1630
+ cwd: defaults.cwd,
1631
+ ...defaults.instructions !== void 0 && { systemPrompt: defaults.instructions },
1632
+ ...rest,
1633
+ ...Object.keys(mcpServers).length > 0 && { mcpServers },
1634
+ toolGate: gate.gate,
1635
+ ...Object.keys(agents).length > 0 && { agents },
1636
+ ...skillDirs.length > 0 && { skillDirs }
1637
+ });
1638
+ session = endOnClose(session, gate.end);
1639
+ if (defaults.supervise) session = defaults.supervise(session);
1640
+ return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
1641
+ }
1603
1642
  const granted = (defaults.readGrants ? await defaults.readGrants() : []).filter((tool) => defaults.askTools.includes(tool));
1604
1643
  const allowedTools = [.../* @__PURE__ */ new Set([
1605
1644
  ...defaults.allowedTools,
@@ -1717,7 +1756,7 @@ async function resolveAppConfig(store, manifest) {
1717
1756
  * `ctx.usage` answers "nothing recorded" — the member is required by the
1718
1757
  * contract, so it is always present.
1719
1758
  */
1720
- async function buildAppContext(store, manifest, provider, usage, approvals) {
1759
+ async function buildAppContext(store, manifest, provider, usage, approvals, gateway) {
1721
1760
  const name = manifest.name;
1722
1761
  const dataDir = store.appDataDir(name);
1723
1762
  const repoDir = store.appRepoDir(name);
@@ -1735,7 +1774,35 @@ async function buildAppContext(store, manifest, provider, usage, approvals) {
1735
1774
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1736
1775
  const logger = createLogger(name);
1737
1776
  let askTools = manifest.permissions.askTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
1738
- if (askTools.length > 0 && provider.supportsApprovals !== true) {
1777
+ const agents = await loadAgents(repoDir, manifest.agents);
1778
+ const gated = gateway !== void 0 && provider.supportsToolGate === true && provider.supportsApprovals === true;
1779
+ const declaresPolicy = manifest.permissions.rules.length > 0 || manifest.permissions.default !== "deny";
1780
+ let gateSession;
1781
+ if (gated) {
1782
+ const base = permissionsPolicy({
1783
+ ...manifest.permissions,
1784
+ allowedTools,
1785
+ askTools
1786
+ });
1787
+ const implicit = Object.keys(agents).length > 0 ? [{
1788
+ id: "implicit.Agent",
1789
+ tool: "Agent",
1790
+ decision: "allow"
1791
+ }] : [];
1792
+ gateSession = (extra) => gateway.session(name, compilePolicy({
1793
+ default: base.default,
1794
+ rules: [
1795
+ ...base.rules,
1796
+ ...implicit,
1797
+ ...permissionsPolicy(extra).rules
1798
+ ]
1799
+ }));
1800
+ } else if (declaresPolicy) {
1801
+ logger.warn(`AI engine "${provider.name}" cannot enforce policy rules; all tools are disabled for this app`);
1802
+ allowedTools.length = 0;
1803
+ askTools = [];
1804
+ }
1805
+ if (!gated && askTools.length > 0 && provider.supportsApprovals !== true) {
1739
1806
  logger.warn(`AI engine "${provider.name}" does not support interactive approvals; ask-tier tools are disabled`, { askTools });
1740
1807
  askTools = [];
1741
1808
  }
@@ -1757,11 +1824,10 @@ async function buildAppContext(store, manifest, provider, usage, approvals) {
1757
1824
  mcpServers,
1758
1825
  allowedTools,
1759
1826
  askTools,
1760
- ...askTools.length > 0 && {
1761
- readGrants: async () => (await store.readGrants(name)).tools,
1762
- ...approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) }
1763
- },
1764
- agents: await loadAgents(repoDir, manifest.agents),
1827
+ ...gateSession !== void 0 && { gateSession },
1828
+ ...(gated || askTools.length > 0) && approvals !== void 0 && { supervise: (session) => superviseSession(session, name, approvals) },
1829
+ ...!gated && askTools.length > 0 && { readGrants: async () => (await store.readGrants(name)).tools },
1830
+ agents,
1765
1831
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
1766
1832
  ...instructions !== void 0 && { instructions },
1767
1833
  cwd: dataDir,
@@ -1814,7 +1880,9 @@ var ApprovalRegistry = class {
1814
1880
  input: event.input,
1815
1881
  requestedAt: new Date(now).toISOString(),
1816
1882
  expiresAt: new Date(now + this.#timeoutMs).toISOString(),
1817
- status: "pending"
1883
+ status: "pending",
1884
+ ...event.ruleId !== void 0 && { ruleId: event.ruleId },
1885
+ ...event.reason !== void 0 && { reason: event.reason }
1818
1886
  };
1819
1887
  const timer = setTimeout(() => this.#timeout(event.approvalId), this.#timeoutMs);
1820
1888
  timer.unref?.();
@@ -2477,6 +2545,45 @@ function roCard(title, children) {
2477
2545
  ]);
2478
2546
  }
2479
2547
 
2548
+ // Every tool call passes the host's tool gateway: user overrides first
2549
+ // (never past a manifest deny), then the manifest's rules, then the default.
2550
+ function ruleRow(r) {
2551
+ return el('div', { class: 'ro-item' }, [
2552
+ el('span', { class: 'chip ' + (r.decision === 'allow' ? 'on' : r.decision === 'deny' ? 'off' : ''),
2553
+ text: r.decision }),
2554
+ document.createTextNode(' '),
2555
+ el('code', { text: r.summary }),
2556
+ el('div', { class: 'desc', text: (r.description && r.description !== r.summary ? r.description + ' · ' : '') + 'id: ' + r.id }),
2557
+ ]);
2558
+ }
2559
+
2560
+ function policyCard(p) {
2561
+ const body = [];
2562
+ if (!p.gated) {
2563
+ body.push(el('div', { class: 'desc', text: 'This AI engine cannot gate individual tool calls; only name-level allow/ask lists apply.' }));
2564
+ }
2565
+ if (p.user.length > 0) {
2566
+ body.push(el('h3', { text: 'Your overrides' }), ...p.user.map(ruleRow));
2567
+ }
2568
+ body.push(el('h3', { text: 'Declared by the app' }));
2569
+ body.push(...(p.manifest.length > 0 ? p.manifest.map(ruleRow) : [el('div', { class: 'desc', text: 'No rules.' })]));
2570
+ body.push(el('div', { class: 'desc', text: 'Anything else: ' + p.default + '.' }));
2571
+ return roCard('Tool policy', body);
2572
+ }
2573
+
2574
+ function toolCallsCard(calls) {
2575
+ return roCard('Recent tool calls', [usageTable(
2576
+ ['When', 'Tool', 'Decision', 'Why', 'Input'],
2577
+ calls.slice().reverse().map((c) => [
2578
+ new Date(c.at).toLocaleString(),
2579
+ c.tool,
2580
+ c.decision + ' (' + c.source + ')',
2581
+ c.reason,
2582
+ c.inputPreview.length > 80 ? c.inputPreview.slice(0, 80) + '…' : c.inputPreview,
2583
+ ]),
2584
+ )]);
2585
+ }
2586
+
2480
2587
  function renderManifest(m) {
2481
2588
  const root = document.getElementById('manifest-cards');
2482
2589
  root.replaceChildren();
@@ -2522,10 +2629,8 @@ function renderManifest(m) {
2522
2629
  ]))));
2523
2630
  }
2524
2631
 
2525
- if (m.allowedTools.length > 0) {
2526
- cards.push(roCard('Allowed tools', m.allowedTools.map((t) =>
2527
- el('div', {}, [el('code', { text: t })]))));
2528
- }
2632
+ if (m.policy) cards.push(policyCard(m.policy));
2633
+ if (m.toolCalls && m.toolCalls.length > 0) cards.push(toolCallsCard(m.toolCalls));
2529
2634
 
2530
2635
  if (m.jobs.length > 0) {
2531
2636
  cards.push(roCard('Jobs', m.jobs.map((j) => el('div', { class: 'ro-item' }, [
@@ -2559,7 +2664,16 @@ async function load() {
2559
2664
  return;
2560
2665
  }
2561
2666
  render(await res.json());
2562
- if (manRes.ok) renderManifest(await manRes.json());
2667
+ if (manRes.ok) {
2668
+ const m = await manRes.json();
2669
+ const [policyRes, callsRes] = await Promise.all([
2670
+ fetch('/api/apps/' + APP + '/policy'),
2671
+ fetch('/api/apps/' + APP + '/tool-calls?limit=20'),
2672
+ ]);
2673
+ if (policyRes.ok) m.policy = await policyRes.json();
2674
+ if (callsRes.ok) m.toolCalls = (await callsRes.json()).calls;
2675
+ renderManifest(m);
2676
+ }
2563
2677
  }
2564
2678
 
2565
2679
  document.getElementById('save').addEventListener('click', async () => {
@@ -2587,6 +2701,193 @@ load();
2587
2701
  `;
2588
2702
  }
2589
2703
  //#endregion
2704
+ //#region src/tool-gateway.ts
2705
+ const INPUT_PREVIEW_CHARS = 1e3;
2706
+ /** Calls the gate answered `ask` and nobody has decided yet, bounded per host. */
2707
+ const PENDING_ASK_CAP = 500;
2708
+ /** The audit ledger is trimmed to this many newest lines... */
2709
+ const AUDIT_KEEP = 5e3;
2710
+ /** ...checked once every this many appends per app. */
2711
+ const AUDIT_TRIM_EVERY = 500;
2712
+ /**
2713
+ * The host's tool gateway: every tool call any app session makes — chat,
2714
+ * cron, app routes; built-in, MCP, subagent — is decided here (see
2715
+ * `SessionOptions.toolGate`). Layers, in order:
2716
+ *
2717
+ * 1. the manifest policy (rules, then `allowedTools`/`askTools` sugar);
2718
+ * 2. the user's overrides (`policy.json`, plus legacy `grants.json`) — which
2719
+ * may tighten anything but never loosen past a manifest `deny`;
2720
+ * 3. session grants ("approve for session"), keyed by the rule that asked;
2721
+ *
2722
+ * and every decision is appended to the app's `tool-calls.jsonl` audit
2723
+ * ledger. `ask` verdicts go on to the provider's interactive approval flow;
2724
+ * `applyDecision` turns the user's answer into a session grant or a
2725
+ * persistent user rule.
2726
+ */
2727
+ var ToolGateway = class {
2728
+ #store;
2729
+ #sessionGrants = /* @__PURE__ */ new Map();
2730
+ #pendingAsks = /* @__PURE__ */ new Map();
2731
+ #appendCounts = /* @__PURE__ */ new Map();
2732
+ /** Serializes ledger appends/trims and policy.json writes. */
2733
+ #queue = Promise.resolve();
2734
+ constructor(store) {
2735
+ this.#store = store;
2736
+ }
2737
+ /** Builds the gate for one new session of `app`. */
2738
+ session(app, manifestPolicy) {
2739
+ const sessionKey = randomUUID();
2740
+ const gate = async (call) => this.#decide(app, sessionKey, manifestPolicy, call);
2741
+ return {
2742
+ gate,
2743
+ end: () => this.#sessionGrants.delete(sessionKey)
2744
+ };
2745
+ }
2746
+ /** The user override layer as stored: policy.json rules, then legacy grants. */
2747
+ async userPolicy(app) {
2748
+ const [policy, grants] = await Promise.all([this.#store.readUserPolicy(app), this.#store.readGrants(app)]);
2749
+ return { rules: [...policy.rules, ...grants.tools.map((tool) => ({
2750
+ id: `grants.${tool}`,
2751
+ tool,
2752
+ decision: "allow"
2753
+ }))] };
2754
+ }
2755
+ /**
2756
+ * Applies the scope of a user's answer to a call this gateway sent to
2757
+ * `ask`: `session` grants the asking rule for the rest of the session,
2758
+ * `always` appends an allow rule with the asking rule's scope to the app's
2759
+ * policy.json. Returns false for approvals this gateway never saw (legacy
2760
+ * name-level sessions), so the caller can fall back.
2761
+ */
2762
+ async applyDecision(approvalId, decision) {
2763
+ const pending = this.#pendingAsks.get(approvalId);
2764
+ if (!pending) return false;
2765
+ this.#pendingAsks.delete(approvalId);
2766
+ if (decision.behavior !== "allow") return true;
2767
+ if (decision.scope === "session" || decision.scope === "always") {
2768
+ let grants = this.#sessionGrants.get(pending.sessionKey);
2769
+ if (!grants) {
2770
+ grants = /* @__PURE__ */ new Set();
2771
+ this.#sessionGrants.set(pending.sessionKey, grants);
2772
+ }
2773
+ grants.add(pending.grantKey);
2774
+ }
2775
+ if (decision.scope === "always") await this.#persistAlways(pending);
2776
+ return true;
2777
+ }
2778
+ /** The newest audit lines for an app, newest last. */
2779
+ async recentCalls(app, limit = 100) {
2780
+ await this.flush();
2781
+ return (await this.#store.readToolCalls(app)).slice(-limit);
2782
+ }
2783
+ /** Resolves when queued ledger and policy writes have landed (tests). */
2784
+ flush() {
2785
+ return this.#queue.then(() => void 0);
2786
+ }
2787
+ async #decide(app, sessionKey, manifestPolicy, call) {
2788
+ const manifest = evaluatePolicy(manifestPolicy, call);
2789
+ let verdict = manifest;
2790
+ let source = manifest.ruleId !== void 0 ? "manifest" : "default";
2791
+ if (manifest.decision !== "deny") {
2792
+ const user = evaluatePolicy(compilePolicy({
2793
+ default: "deny",
2794
+ ...await this.userPolicy(app)
2795
+ }), call);
2796
+ if (user.ruleId !== void 0) {
2797
+ verdict = user;
2798
+ source = "user";
2799
+ }
2800
+ }
2801
+ let result;
2802
+ if (verdict.decision === "ask") {
2803
+ const grantKey = verdict.ruleId ?? `default:${call.tool}`;
2804
+ if (this.#sessionGrants.get(sessionKey)?.has(grantKey)) {
2805
+ source = "session";
2806
+ result = { decision: "allow" };
2807
+ } else {
2808
+ this.#trackAsk(call.toolUseId, {
2809
+ app,
2810
+ sessionKey,
2811
+ grantKey,
2812
+ tool: call.tool,
2813
+ ...verdict.rule !== void 0 && { rule: verdict.rule }
2814
+ });
2815
+ result = {
2816
+ decision: "ask",
2817
+ ...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
2818
+ reason: verdict.reason
2819
+ };
2820
+ }
2821
+ } else if (verdict.decision === "allow") result = { decision: "allow" };
2822
+ else result = {
2823
+ decision: "deny",
2824
+ reason: `Denied by policy: ${verdict.reason}`
2825
+ };
2826
+ this.#audit(app, {
2827
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2828
+ tool: call.tool,
2829
+ toolUseId: call.toolUseId,
2830
+ inputPreview: preview(call.input),
2831
+ decision: result.decision,
2832
+ source,
2833
+ ...verdict.ruleId !== void 0 && { ruleId: verdict.ruleId },
2834
+ reason: source === "session" ? `session grant: ${verdict.reason}` : verdict.reason
2835
+ });
2836
+ return result;
2837
+ }
2838
+ #trackAsk(toolUseId, pending) {
2839
+ this.#pendingAsks.set(toolUseId, pending);
2840
+ if (this.#pendingAsks.size > PENDING_ASK_CAP) {
2841
+ const oldest = this.#pendingAsks.keys().next().value;
2842
+ if (oldest !== void 0) this.#pendingAsks.delete(oldest);
2843
+ }
2844
+ }
2845
+ #persistAlways(pending) {
2846
+ const scope = pending.rule ?? {
2847
+ tool: pending.tool,
2848
+ decision: "ask"
2849
+ };
2850
+ const rule = {
2851
+ id: `always.${pending.grantKey}`,
2852
+ tool: scope.tool,
2853
+ ...scope.input !== void 0 && { input: scope.input },
2854
+ decision: "allow",
2855
+ description: `Always allowed: ${describeRule(scope)}`
2856
+ };
2857
+ const write = this.#queue.then(async () => {
2858
+ const policy = await this.#store.readUserPolicy(pending.app);
2859
+ if (policy.rules.some((existing) => existing.id === rule.id)) return;
2860
+ policy.rules.unshift(rule);
2861
+ await this.#store.writeUserPolicy(pending.app, policy);
2862
+ });
2863
+ this.#queue = write.catch(() => void 0);
2864
+ return write;
2865
+ }
2866
+ #audit(app, record) {
2867
+ const write = this.#queue.then(async () => {
2868
+ await this.#store.appendToolCall(app, record);
2869
+ const count = (this.#appendCounts.get(app) ?? 0) + 1;
2870
+ this.#appendCounts.set(app, count);
2871
+ if (count % AUDIT_TRIM_EVERY === 0) {
2872
+ const records = await this.#store.readToolCalls(app);
2873
+ if (records.length > AUDIT_KEEP) await this.#store.writeToolCalls(app, records.slice(-5e3));
2874
+ }
2875
+ });
2876
+ this.#queue = write.catch((err) => {
2877
+ console.warn(`alashi: failed to write tool-call audit for "${app}": ${String(err)}`);
2878
+ });
2879
+ }
2880
+ };
2881
+ function preview(input) {
2882
+ let json;
2883
+ try {
2884
+ json = JSON.stringify(input) ?? String(input);
2885
+ } catch {
2886
+ json = String(input);
2887
+ }
2888
+ return json.length > INPUT_PREVIEW_CHARS ? `${json.slice(0, INPUT_PREVIEW_CHARS)}…` : json;
2889
+ }
2890
+ //#endregion
2590
2891
  //#region src/server.ts
2591
2892
  const CONTENT_TYPES = {
2592
2893
  ".html": "text/html; charset=utf-8",
@@ -2689,8 +2990,11 @@ function appErrorPage(name, err) {
2689
2990
  * optionally adopting an existing provider session (e.g. an
2690
2991
  * app-run investigation) so chat continues where it left off
2691
2992
  * GET /api/apps/:name/chat/:conversationId/history past turns from the provider transcript
2993
+ * plus the conversation's settled approval records
2692
2994
  * GET /api/apps/:name/jobs manifest-declared jobs with schedule/status
2693
2995
  * POST /api/apps/:name/jobs/:job/run trigger a job now (409 while already running)
2996
+ * GET /api/apps/:name/policy effective tool policy: user overrides + manifest rules
2997
+ * GET /api/apps/:name/tool-calls?limit= newest gated tool calls (audit ledger)
2694
2998
  * GET /api/apps/:name/manifest read-only app definition: instructions, agents,
2695
2999
  * skills (with dir contents), allowed tools,
2696
3000
  * MCP servers (with configured state), jobs, model
@@ -2709,6 +3013,7 @@ function createServer(store, provider, options = {}) {
2709
3013
  const sessions = /* @__PURE__ */ new Map();
2710
3014
  const usage = new UsageRecorder(store);
2711
3015
  const approvals = new ApprovalRegistry(store);
3016
+ const gateway = new ToolGateway(store);
2712
3017
  approvals.init().catch((err) => {
2713
3018
  console.warn(`alashi: failed to read approval timeout config: ${String(err)}`);
2714
3019
  });
@@ -2719,7 +3024,7 @@ function createServer(store, provider, options = {}) {
2719
3024
  });
2720
3025
  async function startApp(name) {
2721
3026
  const { definition, manifest } = await loadApp(store.appRepoDir(name));
2722
- const ctx = await buildAppContext(store, manifest, provider, usage, approvals);
3027
+ const ctx = await buildAppContext(store, manifest, provider, usage, approvals, gateway);
2723
3028
  return {
2724
3029
  manifest,
2725
3030
  ctx,
@@ -3073,7 +3378,12 @@ ${approvalsSection}
3073
3378
  const convo = conversations.find((conv) => conv.id === conversationId);
3074
3379
  if (!convo) return c.json({ error: "Unknown conversation" }, 404);
3075
3380
  const messages = convo.sessionId ? await provider.history?.(convo.sessionId) ?? [] : [];
3076
- return c.json({ messages });
3381
+ await approvals.flush();
3382
+ const settled = (await store.readApprovalLog(name)).approvals.filter((entry) => entry.conversationId === conversationId && entry.status !== "pending");
3383
+ return c.json({
3384
+ messages,
3385
+ approvals: settled
3386
+ });
3077
3387
  });
3078
3388
  /** Drops the cached app and its warm sessions so the next request rebuilds with fresh config. */
3079
3389
  function restartApp(name) {
@@ -3084,6 +3394,33 @@ ${approvalsSection}
3084
3394
  session.then((s) => s.close()).catch(() => void 0);
3085
3395
  }
3086
3396
  }
3397
+ app.get("/api/apps/:name/policy", async (c) => {
3398
+ const name = c.req.param("name");
3399
+ const running = await getApp(name);
3400
+ if (!running) return c.json({ error: `App "${name}" is not installed` }, 404);
3401
+ const manifestPolicy = permissionsPolicy(running.manifest.permissions);
3402
+ const user = await gateway.userPolicy(name);
3403
+ const describe = (rule) => ({
3404
+ ...rule,
3405
+ summary: describeRule(rule)
3406
+ });
3407
+ return c.json({
3408
+ gated: provider.supportsToolGate === true && provider.supportsApprovals === true,
3409
+ default: manifestPolicy.default,
3410
+ user: user.rules.map(describe),
3411
+ manifest: manifestPolicy.rules.map((rule, index) => describe({
3412
+ ...rule,
3413
+ id: rule.id ?? `rules.${index}`
3414
+ }))
3415
+ });
3416
+ });
3417
+ app.get("/api/apps/:name/tool-calls", async (c) => {
3418
+ const name = c.req.param("name");
3419
+ if (!await getApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
3420
+ const raw = Number(c.req.query("limit") ?? 100);
3421
+ const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1e3) : 100;
3422
+ return c.json({ calls: await gateway.recentCalls(name, limit) });
3423
+ });
3087
3424
  app.get("/api/apps/:name/manifest", async (c) => {
3088
3425
  const name = c.req.param("name");
3089
3426
  const running = await getApp(name);
@@ -3314,7 +3651,13 @@ ${approvalsSection}
3314
3651
  if (!decision) return c.json({ error: "Body must be { \"behavior\": \"allow\"|\"deny\", \"scope\"?, \"message\"? }" }, 400);
3315
3652
  const record = approvals.get(approvalId);
3316
3653
  if (!record || expect !== void 0 && (record.app !== expect.app || expect.conversationId !== void 0 && record.conversationId !== expect.conversationId)) return c.json({ error: `Unknown approval "${approvalId}"` }, 404);
3317
- if (decision.behavior === "allow" && decision.scope === "always") {
3654
+ let gatewayHandled;
3655
+ try {
3656
+ gatewayHandled = record.status === "pending" && await gateway.applyDecision(approvalId, decision);
3657
+ } catch (err) {
3658
+ return c.json({ error: `Failed to persist grant: ${String(err)}` }, 500);
3659
+ }
3660
+ if (!gatewayHandled && decision.behavior === "allow" && decision.scope === "always") {
3318
3661
  const running = await getApp(record.app);
3319
3662
  if (running) try {
3320
3663
  await approvals.addGrant(record.app, record.tool, running.manifest.permissions.askTools);
@@ -3447,6 +3790,12 @@ var Store = class {
3447
3790
  appGrantsPath(name) {
3448
3791
  return join(this.appDir(name), "grants.json");
3449
3792
  }
3793
+ appPolicyPath(name) {
3794
+ return join(this.appDir(name), "policy.json");
3795
+ }
3796
+ appToolCallsPath(name) {
3797
+ return join(this.appDir(name), "tool-calls.jsonl");
3798
+ }
3450
3799
  appUsagePath(name) {
3451
3800
  return join(this.appDir(name), "usage.json");
3452
3801
  }
@@ -3493,6 +3842,49 @@ var Store = class {
3493
3842
  async writeGrants(name, state) {
3494
3843
  await writeJson(this.appGrantsPath(name), state);
3495
3844
  }
3845
+ async readUserPolicy(name) {
3846
+ const state = await readJson(this.appPolicyPath(name));
3847
+ return { rules: Array.isArray(state?.rules) ? state.rules : [] };
3848
+ }
3849
+ async writeUserPolicy(name, policy) {
3850
+ await writeJson(this.appPolicyPath(name), policy);
3851
+ }
3852
+ /** Appends one line to the app's tool-call audit ledger. */
3853
+ async appendToolCall(name, record) {
3854
+ const path = this.appToolCallsPath(name);
3855
+ await mkdir(join(path, ".."), { recursive: true });
3856
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
3857
+ }
3858
+ /**
3859
+ * Parseable lines of the tool-call ledger, oldest first. Like the turn
3860
+ * ledger, a truncated or hand-mangled line costs only itself.
3861
+ */
3862
+ async readToolCalls(name) {
3863
+ let content;
3864
+ try {
3865
+ content = await readFile(this.appToolCallsPath(name), "utf8");
3866
+ } catch (err) {
3867
+ if (err.code === "ENOENT") return [];
3868
+ throw err;
3869
+ }
3870
+ const records = [];
3871
+ for (const line of content.split("\n")) {
3872
+ if (line.trim() === "") continue;
3873
+ try {
3874
+ const record = JSON.parse(line);
3875
+ if (typeof record?.at === "string" && typeof record.tool === "string") records.push(record);
3876
+ } catch {}
3877
+ }
3878
+ return records;
3879
+ }
3880
+ /** Replaces the tool-call ledger atomically (tmp file + rename), for trimming. */
3881
+ async writeToolCalls(name, records) {
3882
+ const path = this.appToolCallsPath(name);
3883
+ await mkdir(join(path, ".."), { recursive: true });
3884
+ const tmp = `${path}.tmp`;
3885
+ await writeFile(tmp, records.map((r) => `${JSON.stringify(r)}\n`).join(""), "utf8");
3886
+ await rename(tmp, path);
3887
+ }
3496
3888
  async readJobState(name) {
3497
3889
  const state = await readJson(this.appJobStatePath(name));
3498
3890
  return {
@@ -3591,4 +3983,4 @@ var Store = class {
3591
3983
  //#endregion
3592
3984
  export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
3593
3985
 
3594
- //# sourceMappingURL=store-dipRcfKX.mjs.map
3986
+ //# sourceMappingURL=store-DvLX0Kft.mjs.map