@foldrun/core 1.1.3 → 1.2.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.
@@ -248,6 +248,14 @@ foldrun deploy --url <server> --token $FOLDRUN_TOKEN
248
248
  \`check\` is cheap and catches a broken flow before a schedule fires it at 3am.
249
249
  Run it after edits, the way you would run a typecheck.
250
250
 
251
+ It is also the security scanner: a key written into a file, hidden text in
252
+ knowledge or skills, an agent that reads the web while holding a secret and
253
+ an open way out, \`curl | sh\`, an MCP server at no fixed version. Every
254
+ finding names its rule (\`secret/hardcoded\`). \`foldrun check --security\`
255
+ fails on any of them; \`<!-- foldrun-ignore <rule>: <reason> -->\` on the line
256
+ before accepts one; \`--write-baseline\` accepts what is there today, and
257
+ \`--format sarif\` feeds GitHub code scanning (\`foldrun docs checks\`).
258
+
251
259
  A deploy that would delete files this folder no longer has lists them and
252
260
  stops: with no terminal it is refused. Tell the person what it would delete; add \`--yes\`
253
261
  only when they asked for exactly that deletion.
@@ -345,6 +353,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
345
353
  | \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
346
354
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
347
355
  | \`/vocabulary\` | GET | what this workspace's documents may say |
356
+ | \`/check\` | GET | what \`foldrun check\` says about the deployed copy, as findings with rule ids (\`?security=1\`) |
348
357
  | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
349
358
 
350
359
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
package/index.ts CHANGED
@@ -1,11 +1,11 @@
1
- // @foldrun/core — the open-source runtime.
1
+ // @foldrun/core — the runtime.
2
2
  //
3
3
  // Everything needed to read a workspace of markdown files and run the agents
4
4
  // in it. No server, no database, no account: point it at a folder.
5
5
  //
6
6
  // The hosted platform imports these same modules. That is deliberate — if the
7
- // open version were a reduced copy it would rot, and self-hosting would feel
8
- // like a downgrade instead of a choice. What the platform adds — a queue and
7
+ // local version were a reduced copy it would rot, and running on a laptop
8
+ // would behave differently from running hosted. What the platform adds — a queue and
9
9
  // its worker, metering and billing, pods on a cluster, per-account keys in a
10
10
  // database, previews, share links, tenancy — lives in its own private package
11
11
  // and plugs in through platform.ts. Nothing here imports it.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
- "license": "Apache-2.0",
5
+ "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": {
@@ -30,7 +30,8 @@
30
30
  "./network",
31
31
  "./providers",
32
32
  "./subagents",
33
- "./tool-names"
33
+ "./tool-names",
34
+ "./scan"
34
35
  ]
35
36
  },
36
37
  "main": "./dist/index.js",
@@ -67,9 +68,6 @@
67
68
  "url": "git+https://github.com/foldrun-io/foldrun-core.git"
68
69
  },
69
70
  "homepage": "https://foldrun.io",
70
- "bugs": {
71
- "url": "https://github.com/foldrun-io/foldrun-core/issues"
72
- },
73
71
  "keywords": [
74
72
  "agents",
75
73
  "llm",
package/src/deploy.ts CHANGED
@@ -46,6 +46,7 @@ import { languageProblem } from "./language.ts";
46
46
  import { localeProblems } from "./locale.ts";
47
47
  import { repoDir } from "./gitrepo.ts";
48
48
  import { entriesNoFollow } from "./paths.ts";
49
+ import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
49
50
 
50
51
  /** Directories a deploy never reads out of a source tree. */
51
52
  const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
@@ -228,7 +229,44 @@ export function unopenedFrontmatter(raw: string): string | null {
228
229
  return "the frontmatter has no opening `---`, so none of it is read: the agent runs with no tools and the default model. Add `---` as the first line.";
229
230
  }
230
231
 
232
+ /**
233
+ * What a deploy says but does not refuse: the structural warnings below, and
234
+ * the scanner's findings about what should not be in the files at all — a
235
+ * credential written in, text a reviewer cannot see. The scanner's design
236
+ * advice (an agent that could leak, an unpinned package) is for `foldrun
237
+ * check` and the Checks page, not for every deploy's output.
238
+ */
231
239
  export function deployWarnings(files: DeployFile[]): DeployIssue[] {
240
+ const scanned = scanFiles(files, { only: ["secret", "content"] })
241
+ .filter((f) => f.level !== "info")
242
+ .map((f) => ({ where: `${f.file}${f.line ? `:${f.line}` : ""}`, message: `${f.message} [${f.rule}]` }));
243
+ return [...structuralWarnings(files), ...scanned];
244
+ }
245
+
246
+ /**
247
+ * Everything the platform can say about a workspace's files, as findings
248
+ * with rule ids: what a deploy refuses (errors), what it warns about, and the
249
+ * whole scanner. The Checks page and `GET …/check` answer with this; `foldrun
250
+ * check` asks more (it resolves every library and the platform's), so treat
251
+ * this as the server's view of the same rules, not a second set.
252
+ */
253
+ export function checkFiles(files: DeployFile[], options: ScanOptions = {}): ScanFinding[] {
254
+ const finding = (i: DeployIssue, level: "error" | "warn", kind?: string): ScanFinding => {
255
+ const m = /^(.*?)(?::(\d+))?(?: .*)?$/.exec(i.where);
256
+ const file = m?.[1] || i.where;
257
+ const rule = ruleForWhere(i.where, kind);
258
+ return { rule, level, file, ...(m?.[2] ? { line: Number(m[2]) } : {}), message: i.message, fingerprint: findingFingerprint(rule, file, i.message) };
259
+ };
260
+ const own = [
261
+ ...deployIssues(files).map((i) => finding(i, "error")),
262
+ ...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
263
+ ];
264
+ return [...filterIgnored(own, files), ...scanFiles(files, options)].sort(
265
+ (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule),
266
+ );
267
+ }
268
+
269
+ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string })[] {
232
270
  const front = (content: string): Record<string, unknown> => {
233
271
  try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
234
272
  };
@@ -246,7 +284,7 @@ export function deployWarnings(files: DeployFile[]): DeployIssue[] {
246
284
  // Agents that grant one of them.
247
285
  const outwardAgents: string[] = [];
248
286
  const agentNames: string[] = [];
249
- const out: DeployIssue[] = [];
287
+ const out: (DeployIssue & { kind?: string })[] = [];
250
288
  if (!files.some((f) => /^agents\/[^/]+\/agent\.md$/.test(f.path)) && !files.some((f) => /^\.(claude|agents)\/agents\/[^/]+\.md$/.test(f.path))) {
251
289
  out.push({ where: "agents/", message: "no agents yet — add one (agents/<name>/agent.md), or import one from another workspace" });
252
290
  }
@@ -268,7 +306,7 @@ export function deployWarnings(files: DeployFile[]): DeployIssue[] {
268
306
  let flow;
269
307
  try { flow = parseFlow(path.basename(f.path), f.content); } catch { continue; }
270
308
  for (const w of lintFlow(flow, { agents: agentNames, outwardAgents })) {
271
- if (w.kind === "outward") out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message });
309
+ if (w.kind === "outward") out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message, kind: "outward" });
272
310
  }
273
311
  }
274
312
  return out;
package/src/platform.ts CHANGED
@@ -20,7 +20,7 @@ export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<Container
20
20
 
21
21
  /** Who this install is for. `hosted` means someone is paying the operator
22
22
  * for it — a wallet, a plan, a bill. `self-hosted` is everything else: a
23
- * laptop, a company's own box, the open-source download. */
23
+ * laptop, a dev install, a box nobody bills on. */
24
24
  export type Edition = "self-hosted" | "hosted";
25
25
 
26
26
  /** What a trigger gate decided. `admit: false` is never an error: the
@@ -36,9 +36,9 @@ export interface PlatformHooks {
36
36
  * the wallet, top-ups, plan limits, whatever is added next that assumes
37
37
  * a customer — asks before it renders or answers. Default: self-hosted,
38
38
  * which is what a process that never registered a platform is, and what
39
- * the open-source image is unless its operator configures billing. One
40
- * predicate, one place, so "does the community build show X" is a test
41
- * and not an audit. */
39
+ * any install is unless its operator configures billing. One predicate,
40
+ * one place, so "does an unbilled install show X" is a test and not an
41
+ * audit. */
42
42
  edition(): Edition;
43
43
  /** Put a flow run where a worker will pick it up. Default: start it here, now. */
44
44
  enqueueFlowRun(
@@ -475,7 +475,7 @@ const DOCKER_VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
475
475
  * as a trusted local file, which is precisely the path gVisor, the capability
476
476
  * drop and the egress policy all exist to close. Disk is the cheaper thing to
477
477
  * spend. One volume per tenant keeps that boundary; each carries a
478
- * `foldrun.io/tenant` label so it can be found and pruned (self-hosting/README.md).
478
+ * `foldrun.io/tenant` label so it can be found and pruned.
479
479
  *
480
480
  * The venvs inside carry the runner's own absolute paths, distinct from the
481
481
  * host's `<tenant>/.runtimes` cache — two roots, so a hit is always a real hit.