foldrun 1.1.2 → 1.3.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.
package/LICENSE CHANGED
@@ -1,202 +1,39 @@
1
+ foldrun proprietary licence
1
2
 
2
- Apache License
3
- Version 2.0, January 2004
4
- http://www.apache.org/licenses/
3
+ Copyright (c) 2026 foldrun. All rights reserved.
5
4
 
6
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
5
+ This software, its source code and its documentation are the property of
6
+ foldrun. They are licensed, not sold.
7
7
 
8
- 1. Definitions.
8
+ You may install and run the foldrun packages published to npm (`foldrun`
9
+ and `@foldrun/core`) to write, check, run and deploy your own agents,
10
+ flows and workspaces, on your own machines and with the foldrun platform.
11
+ That includes the copies installing and running need, for your own use:
12
+ package caches and mirrors, CI and build machines, container images and
13
+ backups.
9
14
 
10
- "License" shall mean the terms and conditions for use, reproduction,
11
- and distribution as defined by Sections 1 through 9 of this document.
15
+ You may not, except where the law allows it despite this restriction:
16
+ copy, modify, merge, publish, distribute, sublicense, rent or sell the
17
+ software or any part of it; reverse engineer, decompile or disassemble it;
18
+ or use it to build a product or service that competes with foldrun.
12
19
 
13
- "Licensor" shall mean the copyright owner or entity authorized by
14
- the copyright owner that is granting the License.
20
+ What you write with the software (your agents, flows, knowledge and other
21
+ files) is yours.
15
22
 
16
- "Legal Entity" shall mean the union of the acting entity and all
17
- other entities that control, are controlled by, or are under common
18
- control with that entity. For the purposes of this definition,
19
- "control" means (i) the power, direct or indirect, to cause the
20
- direction or management of such entity, whether by contract or
21
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
- outstanding shares, or (iii) beneficial ownership of such entity.
23
+ Third-party components the software includes or installs (its npm
24
+ dependencies) remain under their own licences.
23
25
 
24
- "You" (or "Your") shall mean an individual or Legal Entity
25
- exercising permissions granted by this License.
26
+ This licence ends automatically if you break its terms, and you must then
27
+ stop using the software.
26
28
 
27
- "Source" form shall mean the preferred form for making modifications,
28
- including but not limited to software source code, documentation
29
- source, and configuration files.
29
+ Versions published before 17 October 2026 under the Apache License 2.0
30
+ remain available under that licence for those versions only.
30
31
 
31
- "Object" form shall mean any form resulting from mechanical
32
- transformation or translation of a Source form, including but
33
- not limited to compiled object code, generated documentation,
34
- and conversions to other media types.
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. TO THE EXTENT THE
35
+ LAW ALLOWS, IN NO EVENT SHALL FOLDRUN BE LIABLE FOR ANY CLAIM, DAMAGES OR
36
+ OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
37
+ ITS USE.
35
38
 
36
- "Work" shall mean the work of authorship, whether in Source or
37
- Object form, made available under the License, as indicated by a
38
- copyright notice that is included in or attached to the work
39
- (an example is provided in the Appendix below).
40
-
41
- "Derivative Works" shall mean any work, whether in Source or Object
42
- form, that is based on (or derived from) the Work and for which the
43
- editorial revisions, annotations, elaborations, or other modifications
44
- represent, as a whole, an original work of authorship. For the purposes
45
- of this License, Derivative Works shall not include works that remain
46
- separable from, or merely link (or bind by name) to the interfaces of,
47
- the Work and Derivative Works thereof.
48
-
49
- "Contribution" shall mean any work of authorship, including
50
- the original version of the Work and any modifications or additions
51
- to that Work or Derivative Works thereof, that is intentionally
52
- submitted to Licensor for inclusion in the Work by the copyright owner
53
- or by an individual or Legal Entity authorized to submit on behalf of
54
- the copyright owner. For the purposes of this definition, "submitted"
55
- means any form of electronic, verbal, or written communication sent
56
- to the Licensor or its representatives, including but not limited to
57
- communication on electronic mailing lists, source code control systems,
58
- and issue tracking systems that are managed by, or on behalf of, the
59
- Licensor for the purpose of discussing and improving the Work, but
60
- excluding communication that is conspicuously marked or otherwise
61
- designated in writing by the copyright owner as "Not a Contribution."
62
-
63
- "Contributor" shall mean Licensor and any individual or Legal Entity
64
- on behalf of whom a Contribution has been received by Licensor and
65
- subsequently incorporated within the Work.
66
-
67
- 2. Grant of Copyright License. Subject to the terms and conditions of
68
- this License, each Contributor hereby grants to You a perpetual,
69
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
- copyright license to reproduce, prepare Derivative Works of,
71
- publicly display, publicly perform, sublicense, and distribute the
72
- Work and such Derivative Works in Source or Object form.
73
-
74
- 3. Grant of Patent License. Subject to the terms and conditions of
75
- this License, each Contributor hereby grants to You a perpetual,
76
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
- (except as stated in this section) patent license to make, have made,
78
- use, offer to sell, sell, import, and otherwise transfer the Work,
79
- where such license applies only to those patent claims licensable
80
- by such Contributor that are necessarily infringed by their
81
- Contribution(s) alone or by combination of their Contribution(s)
82
- with the Work to which such Contribution(s) was submitted. If You
83
- institute patent litigation against any entity (including a
84
- cross-claim or counterclaim in a lawsuit) alleging that the Work
85
- or a Contribution incorporated within the Work constitutes direct
86
- or contributory patent infringement, then any patent licenses
87
- granted to You under this License for that Work shall terminate
88
- as of the date such litigation is filed.
89
-
90
- 4. Redistribution. You may reproduce and distribute copies of the
91
- Work or Derivative Works thereof in any medium, with or without
92
- modifications, and in Source or Object form, provided that You
93
- meet the following conditions:
94
-
95
- (a) You must give any other recipients of the Work or
96
- Derivative Works a copy of this License; and
97
-
98
- (b) You must cause any modified files to carry prominent notices
99
- stating that You changed the files; and
100
-
101
- (c) You must retain, in the Source form of any Derivative Works
102
- that You distribute, all copyright, patent, trademark, and
103
- attribution notices from the Source form of the Work,
104
- excluding those notices that do not pertain to any part of
105
- the Derivative Works; and
106
-
107
- (d) If the Work includes a "NOTICE" text file as part of its
108
- distribution, then any Derivative Works that You distribute must
109
- include a readable copy of the attribution notices contained
110
- within such NOTICE file, excluding those notices that do not
111
- pertain to any part of the Derivative Works, in at least one
112
- of the following places: within a NOTICE text file distributed
113
- as part of the Derivative Works; within the Source form or
114
- documentation, if provided along with the Derivative Works; or,
115
- within a display generated by the Derivative Works, if and
116
- wherever such third-party notices normally appear. The contents
117
- of the NOTICE file are for informational purposes only and
118
- do not modify the License. You may add Your own attribution
119
- notices within Derivative Works that You distribute, alongside
120
- or as an addendum to the NOTICE text from the Work, provided
121
- that such additional attribution notices cannot be construed
122
- as modifying the License.
123
-
124
- You may add Your own copyright statement to Your modifications and
125
- may provide additional or different license terms and conditions
126
- for use, reproduction, or distribution of Your modifications, or
127
- for any such Derivative Works as a whole, provided Your use,
128
- reproduction, and distribution of the Work otherwise complies with
129
- the conditions stated in this License.
130
-
131
- 5. Submission of Contributions. Unless You explicitly state otherwise,
132
- any Contribution intentionally submitted for inclusion in the Work
133
- by You to the Licensor shall be under the terms and conditions of
134
- this License, without any additional terms or conditions.
135
- Notwithstanding the above, nothing herein shall supersede or modify
136
- the terms of any separate license agreement you may have executed
137
- with Licensor regarding such Contributions.
138
-
139
- 6. Trademarks. This License does not grant permission to use the trade
140
- names, trademarks, service marks, or product names of the Licensor,
141
- except as required for reasonable and customary use in describing the
142
- origin of the Work and reproducing the content of the NOTICE file.
143
-
144
- 7. Disclaimer of Warranty. Unless required by applicable law or
145
- agreed to in writing, Licensor provides the Work (and each
146
- Contributor provides its Contributions) on an "AS IS" BASIS,
147
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
- implied, including, without limitation, any warranties or conditions
149
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
- PARTICULAR PURPOSE. You are solely responsible for determining the
151
- appropriateness of using or redistributing the Work and assume any
152
- risks associated with Your exercise of permissions under this License.
153
-
154
- 8. Limitation of Liability. In no event and under no legal theory,
155
- whether in tort (including negligence), contract, or otherwise,
156
- unless required by applicable law (such as deliberate and grossly
157
- negligent acts) or agreed to in writing, shall any Contributor be
158
- liable to You for damages, including any direct, indirect, special,
159
- incidental, or consequential damages of any character arising as a
160
- result of this License or out of the use or inability to use the
161
- Work (including but not limited to damages for loss of goodwill,
162
- work stoppage, computer failure or malfunction, or any and all
163
- other commercial damages or losses), even if such Contributor
164
- has been advised of the possibility of such damages.
165
-
166
- 9. Accepting Warranty or Additional Liability. While redistributing
167
- the Work or Derivative Works thereof, You may choose to offer,
168
- and charge a fee for, acceptance of support, warranty, indemnity,
169
- or other liability obligations and/or rights consistent with this
170
- License. However, in accepting such obligations, You may act only
171
- on Your own behalf and on Your sole responsibility, not on behalf
172
- of any other Contributor, and only if You agree to indemnify,
173
- defend, and hold each Contributor harmless for any liability
174
- incurred by, or claims asserted against, such Contributor by reason
175
- of your accepting any such warranty or additional liability.
176
-
177
- END OF TERMS AND CONDITIONS
178
-
179
- APPENDIX: How to apply the Apache License to your work.
180
-
181
- To apply the Apache License to your work, attach the following
182
- boilerplate notice, with the fields enclosed by brackets "[]"
183
- replaced with your own identifying information. (Don't include
184
- the brackets!) The text should be enclosed in the appropriate
185
- comment syntax for the file format. We also recommend that a
186
- file or class name and description of purpose be included on the
187
- same "printed page" as the copyright notice for easier
188
- identification within third-party archives.
189
-
190
- Copyright 2026 foldrun contributors
191
-
192
- Licensed under the Apache License, Version 2.0 (the "License");
193
- you may not use this file except in compliance with the License.
194
- You may obtain a copy of the License at
195
-
196
- http://www.apache.org/licenses/LICENSE-2.0
197
-
198
- Unless required by applicable law or agreed to in writing, software
199
- distributed under the License is distributed on an "AS IS" BASIS,
200
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
- See the License for the specific language governing permissions and
202
- limitations under the License.
39
+ Questions: hello@foldrun.io
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # foldrun
2
2
 
3
- [![ci](https://github.com/foldrun-io/foldrun-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/foldrun-io/foldrun-cli/actions/workflows/ci.yml)
4
3
  [![npm](https://img.shields.io/npm/v/foldrun)](https://www.npmjs.com/package/foldrun)
5
- [![licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](LICENSE)
6
4
 
7
5
  **Agents are just folders.** An agent is a folder with a markdown file in it. A flow is
8
6
  a numbered list naming agents. You write them, read them, diff them in a pull
@@ -234,6 +232,7 @@ foldrun invoke publish --wait
234
232
  In CI, set `FOLDRUN_URL` and `FOLDRUN_TOKEN` instead — the environment always
235
233
  wins over the credentials file, so a job never reads one from disk.
236
234
 
237
- ## License
235
+ ## Licence
238
236
 
239
- Apache-2.0.
237
+ Proprietary; see [LICENSE](LICENSE). Versions published before 17 October
238
+ 2026 were Apache-2.0 and stay so.
package/bin/foldrun.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // foldrun — the open-source CLI.
2
+ // foldrun — the CLI.
3
3
  //
4
4
  // Local-first by design: every command here works on a plain folder with no
5
5
  // account, no server and no network beyond the model call itself. That is the
@@ -104,6 +104,8 @@ const HELP = `foldrun — agents are just folders
104
104
  foldrun agent ls the agents deployed in a workspace: model, description (--to, --json) — flow ls the flows and how each fires
105
105
  foldrun check [dir] validate every workspace here, and the shared library
106
106
  foldrun check --to <ws> validate the DEPLOYED copy instead — fetched from the platform, checked here
107
+ foldrun check --security only the security rules: hard-coded secrets, hidden text, agents that can leak, unpinned code — fails on a warning
108
+ foldrun check --rules every rule check knows: id, level, what it guards against (--json)
107
109
  foldrun agent run <name> run one agent once on the platform (--task "…", --to, --wait, --test)
108
110
  foldrun agent link <agent> add to its team, in its frontmatter: --subagent <worker>, --consult <agent> or --can-ask (--description for a worker with none)
109
111
  foldrun agent unlink <agent> take one away: --subagent <name>, --consult <name> or --can-ask
@@ -302,7 +304,7 @@ Options
302
304
  --clear secrets sites: unbind it
303
305
  --add <site> agent network: add a site to its network: list (repeatable)
304
306
  --remove <site> agent network: take a site off its network: list (repeatable)
305
- --out <file> api spec: where to write the OpenAPI document (default: stdout)
307
+ --out <file> api spec: where to write the OpenAPI document (default: stdout); check: where to write the JSON or SARIF report
306
308
  --file <path> source put, library put, flow paste: the local file to send (default: stdin); secrets set --kind file|service-account: the file it holds; storage get, report get, report live, account export, export, billing statement: where to write it (- for stdout)
307
309
  --message "<why>" source put: recorded on the file's revision
308
310
  --account secrets: account scope instead of the workspace's
@@ -344,6 +346,11 @@ Platform options (deploy, invoke, secrets, logs, keys)
344
346
  --force deploy: deploy even while runs are in flight; pull, storage get: overwrite local files
345
347
  --yes deploy: allow deleting files this folder no longer has (asked otherwise; required with no terminal)
346
348
  --platform --yes workspaces rm: delete it on the platform, deliberately
349
+ --format <text|json|sarif> check: how to print the findings (--json is --format json; sarif is for GitHub code scanning); --out <file> writes it there
350
+ --fail-on <level> check: exit 1 on a finding at this level or above — error (default), warn, info, never; --security makes it warn
351
+ --rule <id> check: only this rule, or a category (secret, security, content, supply-chain, config); repeat or comma-separate
352
+ --changed check: only findings in files changed against git HEAD (--base <ref> compares with that ref instead, e.g. origin/main)
353
+ --write-baseline check: accept every current finding into .foldrun/check-baseline.json, so later checks show only new ones (--no-baseline ignores it)
347
354
  --platform workspaces new, agent new, flow new, tool new: make it on the platform, not in this folder
348
355
 
349
356
  Nothing here needs an account. Set ANTHROPIC_API_KEY to run; init and check
@@ -360,9 +367,9 @@ if (!command || command === "--help" || command === "-h") {
360
367
  // `--value` as the account's argument and stored an empty secret; `--force
361
368
  // ./dir` swallowed the directory. A flag followed by another flag is also
362
369
  // boolean, so an unlisted switch at least does not eat its neighbour.
363
- const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear"]);
370
+ const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules"]);
364
371
  // Flags said more than once collect into a list: `--tag a --tag b`.
365
- const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove"]);
372
+ const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule"]);
366
373
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
367
374
  // a span: `--wait 30m`, `--wait event`. Only a value that reads as one is
368
375
  // taken, so `invoke x --wait` and friends are as they were.
package/docs/api.md CHANGED
@@ -68,6 +68,7 @@ api workspaces/$WS/flows/publish/run -X POST # start a flow
68
68
  | `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`). A POST to a name that exists deploys over it; add `ifAbsent: true` to create only — a name that exists is then `409` and nothing changes, decided atomically (the assistant's Create workspace and `foldrun assistant` send it) |
69
69
  | `/api/workspaces/<ws>` | GET, PATCH, DELETE | read; rename or re-describe (`name`, `description`), or set its defaults (`timezone`, `budget`, `notify` — null clears one, edited in AGENTS.md in place); delete |
70
70
  | `/api/workspaces/<ws>/vocabulary` | GET | which document kinds this workspace admits — its `docs` list is what `[[ ]]` resolves: knowledge, memory, `state/` and `storage/` files, plus the folders themselves |
71
+ | `/api/workspaces/<ws>/check` | GET | what `foldrun check` says about the deployed copy, as findings with rule ids — what a deploy refuses or warns about, and the scanner's secret, hidden-text, agent-risk and supply-chain rules ([Checks](checks)). `?security=1` keeps the security rules, `?rule=<id or category>` (repeatable) one; `{ counts, findings: [{ rule, level, file, line, message, fingerprint }], rules }`. Read-only, no model call |
71
72
 
72
73
  ## Source — the files themselves
73
74
 
package/docs/checks.md ADDED
@@ -0,0 +1,314 @@
1
+ # Checks
2
+
3
+ `foldrun check` answers two questions about a workspace before anything
4
+ runs: **will it run?** (a tool that exists, a step that names a real agent,
5
+ an option the parser can read) and **is it safe to run?** (a key pasted into
6
+ a prompt, a knowledge file carrying hidden instructions, an agent that reads
7
+ the open web, holds a secret and can send it anywhere). It is offline, calls
8
+ no model and costs nothing, so run it after every edit, the way you would run
9
+ a typecheck, and in CI on every pull request.
10
+
11
+ ```sh
12
+ foldrun check # everything, as text
13
+ foldrun check --security # only the security rules; fails on a warning
14
+ foldrun check --changed # only files changed against git HEAD
15
+ foldrun check --format sarif --out check.sarif # for GitHub code scanning
16
+ foldrun check --rules # every rule, its level, what it guards against
17
+ ```
18
+
19
+ The same findings appear in three other places, from the same rules in
20
+ `@foldrun/core`:
21
+
22
+ - **The dashboard**: the workspace's **Checks** page lists every finding on
23
+ the deployed copy, grouped by rule, each linking to its file. The editor
24
+ shows a file's own findings above it.
25
+ - **The API**: `GET /api/workspaces/<ws>/check` answers with the same
26
+ findings as JSON (`?security=1`, `?rule=`). See [api](api).
27
+ - **A deploy**: what a deploy refuses is still refused. A credential written
28
+ into a file and hidden text are reported as deploy warnings: the deploy
29
+ goes ahead and says so.
30
+
31
+ ## Reading a finding
32
+
33
+ ```
34
+ warn knowledge/setup.md:6 an Anthropic key (sk-a…a5) is written into the file — move it to the vault and write `${NAME}` secret/hardcoded
35
+ ```
36
+
37
+ The level, the file and line, what is wrong, and the **rule id** at the end.
38
+ A secret is never printed whole: the first four characters and the last two
39
+ are enough to find it, and too few to use it.
40
+
41
+ | Level | Means | Fails `check` |
42
+ |---|---|---|
43
+ | `error` | it will not work, or a deploy refuses it | yes |
44
+ | `warn` | it works, and something about it should change | with `--fail-on warn` or `--security` |
45
+ | `info` | worth knowing; often a judgement call | with `--fail-on info` |
46
+
47
+ Every rule added with the scanner is a warning for now, so a workspace that
48
+ passed yesterday still passes. To fail a build on them, use `--security` (which
49
+ fails on a warning) or `--fail-on warn`.
50
+
51
+ ## Options
52
+
53
+ | Flag | Does |
54
+ |---|---|
55
+ | `--security` | only the categories `secret`, `security`, `content` and `supply-chain`; exits 1 on a warning |
56
+ | `--rule <id>` | only this rule, or a whole category (`secret`, `config`, …); repeat it, or comma-separate |
57
+ | `--fail-on <level>` | exit 1 on a finding at `error` (the default), `warn`, `info`, or `never` |
58
+ | `--format text\|json\|sarif` | how to print it; `--json` is `--format json`. `--out <file>` writes it to a file |
59
+ | `--changed` | only findings in files changed against git `HEAD`, tracked or new; `--base <ref>` compares with that ref (`--base origin/main` in CI) |
60
+ | `--write-baseline` | accept every current finding into `.foldrun/check-baseline.json`; later checks show only new ones. `--no-baseline` ignores the file |
61
+ | `--rules` | list every rule (`--json` for the table) |
62
+ | `--to <ws>` | check the copy deployed on the platform instead of this folder |
63
+
64
+ ## Ignoring one
65
+
66
+ Put a comment on the line before the finding, or on the same line, naming
67
+ the rule and saying why:
68
+
69
+ ```markdown
70
+ <!-- foldrun-ignore secret/hardcoded: a revoked key, kept to show the format -->
71
+ ```
72
+
73
+ ```sh
74
+ # foldrun-ignore supply-chain/pipe-to-shell: the vendor's pinned installer, checksum verified below
75
+ ```
76
+
77
+ `foldrun-ignore-file <rule>: <reason>` anywhere in a file covers all of it.
78
+ A category (`secret`) covers its rules. **The reason is required**: an ignore
79
+ without one ignores nothing and is reported itself
80
+ (`check/ignore-needs-reason`), because "why is this allowed?" is the question
81
+ the next reviewer will ask. The same comments work for every rule, check's
82
+ own included, in the CLI, the dashboard and the API.
83
+
84
+ Ignores in `memory/` are not honoured. Agents write their memory
85
+ themselves, so an ignore there could have been written by whatever an agent
86
+ read: an injection that silences its own finding.
87
+
88
+ ## Adopting it on an existing workspace
89
+
90
+ A workspace with years of history will have findings on day one. Do not fix
91
+ them all before the check can be useful:
92
+
93
+ ```sh
94
+ foldrun check --write-baseline # accept what is there today
95
+ git add .foldrun/check-baseline.json && git commit -m "check: baseline"
96
+ foldrun check --security # from now on, only new findings fail
97
+ ```
98
+
99
+ A baseline entry is matched by the rule, the file and the text of the
100
+ flagged line, not by its line number, so adding a paragraph above an
101
+ accepted finding does not bring it back. Changing the flagged line does,
102
+ because then it is a different line. A baseline that is not valid JSON
103
+ hides nothing. `.foldrun/` is never deployed.
104
+
105
+ ## In CI
106
+
107
+ ```yaml
108
+ # .github/workflows/check.yml
109
+ on: [pull_request]
110
+ permissions: { contents: read, security-events: write }
111
+ jobs:
112
+ check:
113
+ runs-on: ubuntu-latest
114
+ steps:
115
+ - uses: actions/checkout@v4
116
+ with: { fetch-depth: 0 }
117
+ - uses: actions/setup-node@v4
118
+ with: { node-version: 22 }
119
+ - run: npm i -g foldrun
120
+ - run: foldrun check --format sarif --out check.sarif --fail-on never
121
+ - uses: github/codeql-action/upload-sarif@v3
122
+ with: { sarif_file: check.sarif }
123
+ - run: foldrun check --security --changed --base origin/${{ github.base_ref }}
124
+ ```
125
+
126
+ The SARIF upload puts each finding on the line it is about, in the pull
127
+ request. The last step fails the build on a new security finding in the
128
+ files the pull request touched. Each result carries a stable fingerprint,
129
+ so code scanning tracks a finding across commits instead of opening a new
130
+ one every time.
131
+
132
+ As a pre-commit hook: `foldrun check --security --changed`.
133
+
134
+ ## The rules
135
+
136
+ The OWASP ids are from the
137
+ [OWASP Top 10 for Agentic Applications (2026)](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026).
138
+
139
+ ### secret/hardcoded
140
+
141
+ *warn · ASI03.* A credential is written into a file: an Anthropic, OpenAI,
142
+ AWS, GitHub, GitLab, Slack, Google, Stripe, SendGrid, Twilio, Telegram, npm,
143
+ Hugging Face, Linear, DigitalOcean, Shopify or Resend key, a JSON web token,
144
+ a private key, a webhook URL that carries its own secret, a password inside a
145
+ URL, or a field named like a credential (`api_key:`, `token:`, `password=`)
146
+ holding a literal value that looks random. Everything in a workspace is
147
+ deployed, versioned, readable by every agent and shown to the model.
148
+
149
+ Move it to the vault (`foldrun secrets set NAME`), write `${NAME}` where it
150
+ was, and bind it to the sites it is for (`foldrun secrets sites NAME
151
+ api.example.com`) so it never enters a sandbox at all. Then **rotate the
152
+ key**: it has already been copied, into git history if nowhere else.
153
+ References (`${NAME}`, `$NAME`, `process.env.X`), names, and obvious samples
154
+ (`your-key-here`, `xxxx`, AWS's `…EXAMPLE`) are not findings.
155
+
156
+ ### content/hidden-unicode
157
+
158
+ *warn · ASI01, ASI04, ASI06.* Zero-width characters, bidirectional overrides
159
+ or Unicode tag characters: invisible to a person reading the file, read as
160
+ text by the model, and the standard way to hide an instruction from a
161
+ reviewer. Tag characters are decoded in the message so you can read what
162
+ they say. Delete them. A byte-order mark at the very start of a file is
163
+ allowed.
164
+
165
+ ### content/injection-phrase
166
+
167
+ *warn · ASI01, ASI06.* A line in knowledge, memory, a skill or a tool's
168
+ description that reads as an order to the agent: "ignore previous
169
+ instructions", "you are no longer…", "new instructions:", a fake `<system>`
170
+ tag, "do not tell the user", "send the secrets to…". Agents read those files
171
+ as material, and a line like that is either an attack that arrived with the
172
+ content or a paste that will confuse the model. Remove it, or quote it as an
173
+ example inside a fenced code block, where it is not flagged. An agent's own
174
+ prompt is not checked, since a guard agent may need to talk about exactly
175
+ this.
176
+
177
+ ### content/encoded-blob
178
+
179
+ *info · ASI01, ASI04.* Three hundred or more characters of base64 in a
180
+ markdown file. A reviewer cannot read it and the model can. Move data to a
181
+ file and link it. An inline image (`data:image/…;base64,`) says what it is
182
+ and is not flagged.
183
+
184
+ ### security/lethal-trifecta
185
+
186
+ *warn · ASI01, ASI02, ASI03.* One agent that **reads untrusted content** (the
187
+ `web` tool, or a browse/fetch/scrape tool), **holds something private** (a
188
+ secret, an `apis:` entry, an MCP server) and **has a way out** (an open
189
+ network, which is the default for an agent that reads the web, a tool marked
190
+ `outward: true`, or an API it may POST, PUT, PATCH or DELETE to). Any page it
191
+ reads can tell it to send what it holds anywhere it can reach.
192
+
193
+ Take a leg away:
194
+ - take it off the open network: `network: { level: trusted, allow:
195
+ [api.example.com] }`, or `network: trusted` ([Network](network)). A bare
196
+ list of sites only adds to a level, so an agent that reads the web stays
197
+ `open` with one. The level is resolved the way a run resolves it: the
198
+ agent's, then the workspace's `AGENTS.md`, then the account's;
199
+ - split it into a reader with no secrets and a sender that reads nothing
200
+ untrusted;
201
+ - guard its calls with a `hooks:` gate ([Hooks](hooks)).
202
+
203
+ `permissionMode: plan` also clears it. An approval (`!`) on each outward step
204
+ is the other answer, though this rule looks at the agent, not the flow.
205
+
206
+ ### security/subagent-wider
207
+
208
+ *info · ASI03.* A sub-agent declares tools its parent does not have. A
209
+ sub-agent never gets more than its parent, so they are dropped inside the
210
+ delegation. Grant them to the parent, or remove them from the sub-agent.
211
+
212
+ ### flow/outward-ungated
213
+
214
+ *the step's level · ASI02, ASI09.* A step whose agent holds an `outward:
215
+ true` tool, with no `verify:` and no approval. Give it a `verify:` that reads
216
+ what it did, or a `!`, so a wrong post or send is caught before it happens
217
+ again. An error in `foldrun check`, a warning at deploy.
218
+
219
+ ### supply-chain/pipe-to-shell
220
+
221
+ *warn · ASI04, ASI05.* `curl … | sh` (or `wget`, `bash`, `zsh`) runs whatever
222
+ the server returns today, unreviewed, in the sandbox that holds the
223
+ workspace. Declare the package under `runtime: system:` or `packages:`, or
224
+ download it, verify a checksum, then run it.
225
+
226
+ ### supply-chain/unpinned-mcp
227
+
228
+ *warn · ASI04.* An MCP server started with `npx`, `bunx`, `pnpx` or `uvx`
229
+ with no version, or at `@latest`. Every run installs whatever was published
230
+ last, so a compromised release reaches you the moment it ships. Pin it:
231
+ `npx -y some-server@1.4.2`.
232
+
233
+ ### supply-chain/unpinned-package
234
+
235
+ *info · ASI04.* A `runtime: packages:` or `npm:` entry with no version. Pin a
236
+ range (`pandas>=2,<3`, `lodash@^4`) so tomorrow's run uses what was tested
237
+ today.
238
+
239
+ ### config/unknown-field
240
+
241
+ *warn or info.* A frontmatter key that this kind of file does not read. There
242
+ are three cases, each with its own message:
243
+ - **A near-miss** of a real field (`tolls:`, `triger:`, `disallowed_tools:`) is
244
+ a warning that names the field it meant. YAML accepts a typo, so it is
245
+ silently ignored, and `tolls: [web]` leaves the agent with no tools.
246
+ - **A field read somewhere else** is a warning that says where it goes.
247
+ `timeout: 3600` on an agent is a step option, so it belongs under the step
248
+ in the flow. `language:` on a flow is read from the agent or `AGENTS.md`.
249
+ - **A field like nothing foldrun reads** (`favourite_colour:`) is a note,
250
+ since another tool may own it.
251
+
252
+ ### config/wrong-type
253
+
254
+ *warn.* The right field holding the wrong kind of value: a map where a list
255
+ of names goes (`tools: {web: true}`), a list where one word goes. The runtime
256
+ does not read it. The message says what the field takes.
257
+
258
+ ### config/invalid-value
259
+
260
+ *warn.* A value that does nothing, or quietly does something else:
261
+ - `effort: extreme` runs at the model's default;
262
+ - `size: giant` becomes `large`;
263
+ - `permissionMode:` other than `plan` is ignored;
264
+ - `trigger: sometimes` never fires;
265
+ - a `schedule:` the scheduler cannot read (`every day`) never fires. This is
266
+ checked with the scheduler's own cron parser;
267
+ - an unknown HTTP method in `methods:` is dropped;
268
+ - a duration that is not one (`debounce: soon`).
269
+
270
+ Aliases count as valid: `effort: quick` is `low`. Fields another rule already
271
+ checks in depth (`network:`, `limits:`, `timezone:`, `web:`) are left to it,
272
+ so nothing is reported twice.
273
+
274
+ ### config/missing-field
275
+
276
+ *warn.* A required field is absent: a skill with no `description:` is never
277
+ loaded, because the model chooses skills by their descriptions.
278
+
279
+ Every field these four rules know is listed, with its type and values, in one
280
+ place in `@foldrun/core` (`frontmatter-fields.ts`). The editor's completions
281
+ come from the same list, so what the editor suggests and what `check`
282
+ accepts cannot drift apart.
283
+
284
+ ### check/ignore-needs-reason
285
+
286
+ *warn.* A `foldrun-ignore` with no reason after a colon. It ignores nothing
287
+ until it has one.
288
+
289
+ ### check's own rules
290
+
291
+ These are what `check` has always reported, now with ids so they can be
292
+ filtered, ignored and baselined. Each problem keeps its own level.
293
+
294
+ | Rule | Covers |
295
+ |---|---|
296
+ | `agent/config` | frontmatter that cannot be read; a tool, skill, colleague, sub-agent or hook that does not exist; retired tool names |
297
+ | `flow/config` | a step naming nothing, an option the parser cannot read, `verify:` written as prose, an arrangement that cannot have what its instruction asks for |
298
+ | `tool/config` | a `run:` program that is missing, an import nothing declares, a leftover `type:` |
299
+ | `skill/config` | a `SKILL.md` without a usable name or description |
300
+ | `eval/config` | an eval that names nothing, or has no cases |
301
+ | `hook/config` | a hook folder that is not a valid hook; a `hooks:` name that resolves to nothing |
302
+ | `knowledge/okf` | a knowledge or memory document that is not a conformant bundle entry, an unreadable date, a stale document |
303
+ | `workspace/config` | an `AGENTS.md` setting that cannot be read; a path a deploy refuses |
304
+ | `library/config` | the same, in the account's shared library |
305
+ | `paths/workspace-spelling` | `../../storage/x` where `workspace/storage/x` reads the same everywhere |
306
+
307
+ ## What it does not do
308
+
309
+ It reads files; it does not run them. Static rules catch what the files
310
+ say, not what a model will do with a page it has not read yet, so they are
311
+ the first line, not the only one: [network](network) policy, bound
312
+ [secrets](secrets), [hooks](hooks) and approvals hold at run time whatever
313
+ the files say. MCP servers are never started to inspect their tools, and no
314
+ URL is fetched.
package/docs/cli.md CHANGED
@@ -39,7 +39,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
39
39
  | `foldrun agent ls` | the agents deployed in a workspace — name, model, description (`GET …/agents`); `flow ls` the flows and how each fires (`GET …/flows`). `--to <workspace>`, `--json` for the list as the platform returns it |
40
40
  | `foldrun guide` | the coding-agent rules, copied from what Next.js does: a short managed block in the account's `AGENTS.md`, between `<!-- BEGIN:foldrun-agent-rules -->` markers, telling a coding agent (Claude Code, Cursor, Codex) to read `foldrun docs` before it writes an agent, flow or tool, and a `CLAUDE.md` that imports it with `@AGENTS.md`. `foldrun init` writes them, `foldrun pull` refreshes them, and `foldrun check` re-adds them when a coding agent runs it and they are missing or old — as `next dev` does. Only the block is rewritten, in place; text around it is kept. The runtime strips the block, so the agents that run in the account never see it; the rest of `AGENTS.md` stays their shared context. `--check` exits 1 when the block is missing or old (for CI), `--print` writes it to stdout |
41
41
  | `foldrun docs [page]` | these docs, from the copy that ships inside the CLI — the way Next.js ships `node_modules/next/dist/docs` — so a coding agent reads the version it is driving, offline. No page lists them; `foldrun docs flows` prints one; `--path` prints the folder. Design records and the platform operator's routes are left out |
42
- | `foldrun check [dir]` | validate agents, flows, tools, evals, knowledge — offline. At an account root: every workspace, then the shared library. Includes each agent's colleagues: an `agents:` or `subagents:` name that is not an agent, a sub-agent with no `description:`, a sub-agent that would hold none of its tools inside its parent's. Signed in, it also asks the platform's open `/api/version` which Chrome its runner image has, and warns when a pinned `web.browse.user_agent:` claims another major ([why](tools#how-this-agents-browser-presents-itself)); it warns about the old `web.browse.session:` key too (now `vendor_session:`). Hooks: a `hooks/<name>/` folder that is not a valid hook (`on:`, `run.mjs` beside `hook.md`, `tool:` only on tool events, `timeout:` 1 to 120), and a `hooks:` name in an agent or an AGENTS.md that is in neither `hooks/` nor the library, are errors; a `tool.call` hook on an agent whose `web.search:` (or `web.fetch:`) the model provider runs on its own servers is a warning — those calls never reach the hook |
42
+ | `foldrun check [dir]` | validate agents, flows, tools, evals, knowledge — offline. At an account root: every workspace, then the shared library. Includes each agent's colleagues: an `agents:` or `subagents:` name that is not an agent, a sub-agent with no `description:`, a sub-agent that would hold none of its tools inside its parent's. Signed in, it also asks the platform's open `/api/version` which Chrome its runner image has, and warns when a pinned `web.browse.user_agent:` claims another major ([why](tools#how-this-agents-browser-presents-itself)); it warns about the old `web.browse.session:` key too (now `vendor_session:`). Hooks: a `hooks/<name>/` folder that is not a valid hook (`on:`, `run.mjs` beside `hook.md`, `tool:` only on tool events, `timeout:` 1 to 120), and a `hooks:` name in an agent or an AGENTS.md that is in neither `hooks/` nor the library, are errors; a `tool.call` hook on an agent whose `web.search:` (or `web.fetch:`) the model provider runs on its own servers is a warning — those calls never reach the hook. It is also the security scanner: a credential written into any file, invisible Unicode, override phrases in knowledge and skills, an agent that reads the web while holding a secret and an open way out, `curl \| sh`, an MCP server at no fixed version, a typo of a frontmatter field. Every finding names its rule (`secret/hardcoded`, `agent/config`, …); see [Checks](checks) |
43
+ | `foldrun check --security` | only the security rules (secret, security, content, supply-chain), exiting 1 on a warning. Also `--rule <id or category>` (repeatable), `--fail-on error\|warn\|info\|never`, `--format text\|json\|sarif` (`--json`; `--out <file>`), `--changed` (only files changed against git HEAD; `--base <ref>`), `--write-baseline` (accept today's findings into `.foldrun/check-baseline.json`; `--no-baseline`), `--rules` (list every rule). A `foldrun-ignore <rule>: <reason>` comment on the line before a finding silences it; the reason is required. [Checks](checks) |
43
44
  | `foldrun extract [dir]` | move each single-file script tool — a `tools/<name>.md` with its program in a fenced block — into a folder: `tools/<name>/tool.md` with `run:` pointing at `run.js`, `run.py` or `run.sh` beside it. Frontmatter is edited as text, never re-serialised, so the diff is only what moved. A tool that already has a folder is left for you; `--dry-run` lists what it would do |
44
45
  | `foldrun check --to <ws>` | validate the copy that is DEPLOYED, rather than the folder in front of you — a workspace edited through the API, by an agent or by `foldrun source put`, has had no way to be checked short of spending a run. The files are fetched into a temporary folder and the ordinary check runs over them, so there is one copy of the rules; the output names the workspace and the platform it checked |
45
46
  | `foldrun agent run <name>` | run one agent once on a platform, with no flow file: a flow of one step. `--task "…"` is the instruction and is required. Prints the run id and how to follow it; `--wait` holds on (in 25-second pieces, as `invoke` does) and then prints the same report `foldrun report` would; `--test` makes it a [test run](runs#test-runs) |
package/docs/faq.md CHANGED
@@ -52,5 +52,5 @@ file store. Nothing is hidden in a database only the platform can read, and
52
52
  a deploy never touches what an agent learned or a run recorded.
53
53
 
54
54
  **Can I run it on my own hardware?** The CLI and runtime, yes — that is
55
- what they are for, and they are open source. The platform itself is hosted
55
+ what they are for, and they are free. The platform itself is hosted
56
56
  only. See [Running it yourself](self-hosting).
@@ -36,7 +36,7 @@ The design bets on three things:
36
36
 
37
37
  The same workspace moves between them with `foldrun deploy` or a `git push`.
38
38
  Start locally; that is where every workspace should be born. The CLI and
39
- runtime are open source (Apache-2.0); the platform is not distributed — see
39
+ runtime are free from npm; the platform is not distributed — see
40
40
  [Running it yourself](self-hosting).
41
41
 
42
42
  ## Where to go
@@ -5,11 +5,12 @@ them unchanged.
5
5
 
6
6
  | shape | for | what is yours |
7
7
  |---|---|---|
8
- | **your machine** | one person, no server — the open-source CLI and runtime | everything: the files, the runs, the model bill |
8
+ | **your machine** | one person, no server — the foldrun CLI and runtime, free from npm | everything: the files, the runs, the model bill |
9
9
  | **the hosted platform** | a team; schedules, sandboxes, gates, a vault, a dashboard | your workspaces and their records; we run the machines |
10
10
 
11
- The framework — the format, the runner, the checks, the CLI — is Apache-2.0
12
- on [GitHub](https://github.com/foldrun-io) and npm. The platform that runs it
11
+ The framework — the format, the runner, the checks, the CLI — is free to
12
+ install from npm (`foldrun`, `@foldrun/core`) and use under foldrun's
13
+ licence, which ships in each package. The platform that runs it
13
14
  for other people (the queue and its worker, per-step sandboxes on a cluster,
14
15
  metering, tenancy) is ours and is not distributed: there is no self-hosted
15
16
  platform. If you need it inside your own network, that is a conversation —
package/docs/tools.md CHANGED
@@ -1787,6 +1787,13 @@ Run `scripts/pace.py --spent <amount> --budget <amount>` from this skill's
1787
1787
  folder, then report the verdict in one sentence.
1788
1788
  ```
1789
1789
 
1790
+ | field | |
1791
+ |---|---|
1792
+ | `name` | defaults to the folder's name |
1793
+ | `description` | **required**: what it does and when to use it. A skill without one is never loaded, because the model chooses skills by their descriptions |
1794
+ | `when` | run tags: the skill is loaded only on runs carrying one of them |
1795
+ | `license`, `compatibility`, `metadata`, `allowed-tools` | the Agent Skills format's optional fields. Accepted and carried, not read by foldrun |
1796
+
1790
1797
  Only names and descriptions sit in context; the agent reads the full file when
1791
1798
  a task matches. So an agent can carry many skills for a few tokens each — which
1792
1799
  is the reason to move a procedure out of `agent.md` in the first place.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
- "license": "Apache-2.0",
5
+ "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "foldrun": "bin/foldrun.mjs"
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "dependencies": {
14
- "@foldrun/core": "^1.1.0"
14
+ "@foldrun/core": "^1.4.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
24
- "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts",
24
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts",
25
25
  "release": "node scripts/release.mjs",
26
26
  "typecheck": "tsc -p tsconfig.json",
27
27
  "docs:sync": "node scripts/sync-docs.mjs",
@@ -33,9 +33,6 @@
33
33
  "url": "git+https://github.com/foldrun-io/foldrun-cli.git"
34
34
  },
35
35
  "homepage": "https://foldrun.io",
36
- "bugs": {
37
- "url": "https://github.com/foldrun-io/foldrun-cli/issues"
38
- },
39
36
  "keywords": [
40
37
  "agents",
41
38
  "llm",
package/src/commands.mjs CHANGED
@@ -758,54 +758,355 @@ async function platformBrowsers(url) {
758
758
  */
759
759
  async function checkAccount(layout, flags) {
760
760
  const { readLibraryTree, LIBRARY_KINDS } = await core();
761
- let worst = 0;
761
+ const opts = checkOptions(flags);
762
+ const text = opts.format === "text";
762
763
  if (!layout.workspaces.length) {
763
764
  console.log(`\n ${c.amber("!")} no workspaces in ${layout.accountRoot} — \`foldrun new <name>\` makes one\n`);
764
765
  return 1;
765
766
  }
767
+ const all = [];
768
+ let baselined = 0;
766
769
  for (const name of layout.workspaces) {
767
770
  const dir = path.join(layout.workspacesDir, name);
768
771
  process.env.FOLDRUN_WORKSPACE = dir;
769
- console.log(`\n ${c.bold(name)} ${c.dim(path.relative(layout.accountRoot, dir))}`);
770
- worst = Math.max(worst, (await check(dir, flags)) ?? 0);
772
+ const r = await checkWorkspace(dir, flags, opts);
773
+ baselined += r.baselined;
774
+ if (text) {
775
+ console.log(`\n ${c.bold(name)} ${c.dim(path.relative(layout.accountRoot, dir))}`);
776
+ printCheck(r, opts);
777
+ }
778
+ const prefix = path.relative(layout.accountRoot, dir).split(path.sep).join("/");
779
+ // A library finding is the account's, reported once however many
780
+ // workspaces resolve that tool.
781
+ for (const f of r.findings) {
782
+ if (!f.account) all.push({ ...f, file: `${prefix}/${f.file}`, where: `${prefix}/${f.where}` });
783
+ else if (!all.some((x) => x.account && x.rule === f.rule && x.file === f.file && x.line === f.line && x.fingerprint === f.fingerprint)) all.push(f);
784
+ }
771
785
  }
772
786
 
773
787
  // The library, once — it is one thing however many workspaces read it.
774
788
  const files = readLibraryTree(layout.accountRoot);
775
789
  const problems = [];
776
- const note = (level, where, message) => problems.push({ level, where, message });
790
+ const note = (level, where, message) => problems.push({ level, where, message, rule: checkRuleFor(`library/${where}`) });
777
791
  validateSkills(path.join(layout.accountRoot, "library"), note);
778
792
  const hc = await hooksCore();
779
793
  for (const p of hc ? hc.checkHooksDir(path.join(layout.accountRoot, "library", "hooks"), "account") : []) {
780
794
  const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
781
795
  note("error", m ? m[1] : "hooks/", m ? m[2] : p);
782
796
  }
783
- const counts = LIBRARY_KINDS.map((k) => ({ kind: k, n: files.filter((f) => f.kind === k).length })).filter((x) => x.n > 0);
784
- console.log(`\n ${c.bold("library")} ${c.dim(counts.length ? counts.map((x) => `${x.n} ${x.kind}`).join(" · ") : "empty")}`);
785
- for (const p of problems) {
786
- console.log(` ${p.level === "error" ? c.red("✗") : c.amber("!")} ${c.bold(`library/${p.where}`)} ${p.message}`);
797
+ const scan = await scanCore();
798
+ const fp = scan?.findingFingerprint ?? localFingerprint;
799
+ let lib = problems.map((p) => toFinding({ ...p, where: `library/${p.where}` }, layout.accountRoot, fp));
800
+ if (scan?.filterIgnored) lib = scan.filterIgnored(lib, files.map((f) => ({ path: `library/${f.kind}/${f.path}`, content: f.content })));
801
+ if (scan) {
802
+ const outward = files.filter((f) => f.kind === "tools" && /^[^/]+(?:\/tool)?\.md$/.test(f.path) && /^outward:\s*true\s*$/m.test(f.content)).map((f) => f.path.replace(/(?:\/tool)?\.md$/, ""));
803
+ lib.push(...scan.scanFiles(files.map((f) => ({ path: `library/${f.kind}/${f.path}`, content: f.content })), { outwardTools: outward }).map((f) => ({ ...f, where: `${f.file}${f.line ? `:${f.line}` : ""}` })));
804
+ }
805
+ const libResult = narrowCheck(lib, layout.accountRoot, opts, "library");
806
+ baselined += libResult.baselined;
807
+ if (text) {
808
+ const counts = LIBRARY_KINDS.map((k) => ({ kind: k, n: files.filter((f) => f.kind === k).length })).filter((x) => x.n > 0);
809
+ console.log(`\n ${c.bold("library")} ${c.dim(counts.length ? counts.map((x) => `${x.n} ${x.kind}`).join(" · ") : "empty")}`);
810
+ for (const f of libResult.findings) {
811
+ console.log(` ${f.level === "error" ? c.red("✗") : f.level === "warn" ? c.amber("!") : c.dim("·")} ${c.bold(f.where)} ${f.message} ${c.dim(f.rule)}`);
812
+ }
813
+ if (libResult.wrote) console.log(` ${c.dim(`baseline: ${libResult.wrote}`)}`);
814
+ console.log();
787
815
  }
788
- if (problems.some((p) => p.level === "error")) worst = 1;
789
- console.log();
790
- return worst;
816
+ all.push(...libResult.findings);
817
+ if (!text) emitCheck(all, opts, { root: layout.accountRoot, summary: `${layout.workspaces.length} workspaces and the library`, baselined });
818
+ return checkExit(all, opts);
791
819
  }
792
820
 
793
821
  async function check(workspace, flags = {}) {
794
- const { problems, summary } = await checkProblems(workspace, flags);
795
- const errors = problems.filter((p) => p.level === "error");
796
- const warnings = problems.filter((p) => p.level === "warn");
822
+ const opts = checkOptions(flags);
823
+ const r = await checkWorkspace(workspace, flags, opts);
824
+ if (opts.format === "text") printCheck(r, opts);
825
+ else emitCheck(r.findings, opts, { root: workspace, summary: r.summary, baselined: r.baselined });
826
+ return checkExit(r.findings, opts);
827
+ }
797
828
 
829
+ function printCheck(r, opts) {
830
+ const errors = r.findings.filter((p) => p.level === "error");
831
+ const warnings = r.findings.filter((p) => p.level === "warn");
798
832
  console.log("");
799
- for (const p of problems) {
833
+ for (const p of r.findings) {
800
834
  const tag = p.level === "error" ? c.red("error") : p.level === "warn" ? c.amber(" warn") : c.dim(" info");
801
- console.log(` ${tag} ${c.bold(p.where)} ${p.message}`);
802
- }
835
+ console.log(` ${tag} ${c.bold(p.where)} ${p.message} ${c.dim(p.rule)}`);
836
+ }
837
+ const notes = [
838
+ ...(r.baselined ? [`${r.baselined} known in the baseline`] : []),
839
+ ...(opts.changed ? [`changed files only (vs ${opts.base})`] : []),
840
+ ...(opts.security ? ["security rules only"] : []),
841
+ ...(r.wrote ? [`baseline written: ${r.wrote}`] : []),
842
+ ...(r.noScanner ? ["this @foldrun/core has no scanner — `npm i -g foldrun@latest` for the security rules"] : []),
843
+ ];
844
+ const tail = notes.length ? ` ${c.dim(`(${notes.join("; ")})`)}` : "";
803
845
  console.log(
804
846
  errors.length === 0 && warnings.length === 0
805
- ? ` ${c.green("✓")} ${summary} — no problems\n`
806
- : `\n ${summary} · ${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}\n`,
847
+ ? ` ${c.green("✓")} ${r.summary} — no problems${tail}\n`
848
+ : `\n ${r.summary} · ${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}${tail}\n`,
807
849
  );
808
- return errors.length ? 1 : 0;
850
+ }
851
+
852
+ // ---------------------------------------------------------------- check: rules, formats, baseline
853
+ //
854
+ // `check` is the linter and the scanner in one: its own rules (will this
855
+ // run?) and core's scanner (is it safe to run? — secrets, hidden text, an
856
+ // agent that can be talked into leaking, unpinned code). Every finding has a
857
+ // rule id, a level, a file and a fingerprint, so the same result prints for
858
+ // a person, as JSON for a script, or as SARIF for GitHub code scanning.
859
+
860
+ /** Core's scanner, or null on a core from before it — check then runs its
861
+ * own rules alone and says the security rules need a newer install. */
862
+ async function scanCore() {
863
+ try {
864
+ scanRules = /** @type {any} */ (await import("@foldrun/core/scan"));
865
+ return scanRules;
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+
871
+ // The scanner module, once scanCore has loaded it — categoryOf and SARIF read it.
872
+ let scanRules = null;
873
+
874
+ const SECURITY_CATEGORIES = new Set(["secret", "security", "content", "supply-chain"]);
875
+ const RANK = { error: 3, warn: 2, info: 1 };
876
+ const FAIL_ON = { error: 3, warn: 2, info: 1, never: Infinity };
877
+
878
+ /** The rule id of one of check's own problems, from where it was found —
879
+ * core's ruleForWhere, so the deploy and the Checks page name the same
880
+ * problem the same way. A core from before the scanner has no table, and
881
+ * then this fallback reads the path the same way. */
882
+ function checkRuleFor(where, kind) {
883
+ if (scanRules?.ruleForWhere) return scanRules.ruleForWhere(where, kind);
884
+ if (kind === "outward") return "flow/outward-ungated";
885
+ if (kind === "spelling") return "paths/workspace-spelling";
886
+ if (/(?:^|\/)(?:knowledge|memory)\//.test(where)) return "knowledge/okf";
887
+ const lib = where.startsWith("library/");
888
+ const top = where.replace(/^library\//, "").split(/[/:]/)[0];
889
+ const byTop = { agents: "agent/config", flows: "flow/config", tools: "tool/config", skills: "skill/config", ".agents": "skill/config", evals: "eval/config", hooks: "hook/config" };
890
+ if (byTop[top]) return byTop[top];
891
+ return lib || top === "library" ? "library/config" : "workspace/config";
892
+ }
893
+
894
+ function categoryOf(rule) {
895
+ return scanRules?.scanRule?.(rule)?.category ?? (rule === "flow/outward-ungated" ? "security" : "config");
896
+ }
897
+
898
+ function checkOptions(flags) {
899
+ const format = flags.json === true ? "json" : typeof flags.format === "string" ? flags.format : "text";
900
+ if (!["text", "json", "sarif"].includes(format)) throw new Error(`--format ${format} — use text, json or sarif`);
901
+ const security = flags.security === true;
902
+ const failOn = typeof flags["fail-on"] === "string" ? flags["fail-on"] : security ? "warn" : "error";
903
+ if (!(failOn in FAIL_ON)) throw new Error(`--fail-on ${failOn} — use error, warn, info or never`);
904
+ const rules = [].concat(flags.rule ?? []).flatMap((r) => String(r).split(",")).map((r) => r.trim()).filter(Boolean);
905
+ return {
906
+ format,
907
+ security,
908
+ failOn,
909
+ rules,
910
+ changed: flags.changed === true || typeof flags.base === "string",
911
+ base: typeof flags.base === "string" ? flags.base : "HEAD",
912
+ baseline: flags["no-baseline"] !== true,
913
+ writeBaseline: flags["write-baseline"] === true,
914
+ out: typeof flags.out === "string" ? flags.out : null,
915
+ };
916
+ }
917
+
918
+ /** sha256 over rule, file and text — core's findingFingerprint, for a core
919
+ * without the scanner, so a baseline written by either matches. */
920
+ function localFingerprint(rule, file, text) {
921
+ return crypto.createHash("sha256").update(`${rule}\0${file}\0${String(text).trim().replace(/\s+/g, " ")}`).digest("hex").slice(0, 16);
922
+ }
923
+
924
+ /** One of check's problems as a finding: a file (not a folder) and a line. */
925
+ function toFinding(p, root, fp) {
926
+ const m = /^(.*?):(\d+)$/.exec(p.where);
927
+ let file = m ? m[1] : p.where;
928
+ const line = m ? Number(m[2]) : undefined;
929
+ if (/^agents\/[^/]+$/.test(file)) file += "/agent.md";
930
+ else if (/^(?:library\/)?tools\/[^/]+$/.test(file)) file = fs.existsSync(path.join(root, file, "tool.md")) ? `${file}/tool.md` : `${file}.md`;
931
+ const rule = p.rule ?? checkRuleFor(p.where);
932
+ return { rule, level: p.level, file, ...(line ? { line } : {}), message: p.message, fingerprint: fp(rule, file, p.message), where: p.where };
933
+ }
934
+
935
+ /**
936
+ * The file a finding is really in. readTree carries a single-file subagent
937
+ * (`.agents/agents/<name>.md`, `.claude/agents/<name>.md`) as
938
+ * `agents/<name>/agent.md`, so a finding on it names a file that is not
939
+ * there — and --changed, which asks git, would drop it. The content is the
940
+ * same, so the line still holds.
941
+ */
942
+ function sourcePathOf(workspace, file) {
943
+ const m = /^agents\/([^/]+)\/agent\.md$/.exec(file);
944
+ if (!m || fs.existsSync(path.join(workspace, file))) return file;
945
+ // The same eligibility readTree uses: a regular file, never a link.
946
+ for (const alt of [`.agents/agents/${m[1]}.md`, `.claude/agents/${m[1]}.md`]) {
947
+ try {
948
+ if (fs.lstatSync(path.join(workspace, alt)).isFile()) return alt;
949
+ } catch {
950
+ // not there
951
+ }
952
+ }
953
+ return file;
954
+ }
955
+
956
+ /** Everything check finds in one workspace, narrowed by the flags. Findings
957
+ * about the account library carry `account: true` and paths from the
958
+ * account root, where they live — not from this workspace. */
959
+ async function checkWorkspace(workspace, flags, opts) {
960
+ const { problems, summary, outwardTools } = await checkProblems(workspace, flags);
961
+ const scan = await scanCore();
962
+ const { libraryDir } = await core();
963
+ const accountRoot = path.dirname(libraryDir("default"));
964
+ const fp = scan?.findingFingerprint ?? localFingerprint;
965
+ /** @type {any[]} */
966
+ let findings = problems.map((p) => ({ ...toFinding(p, p.where.startsWith("library/") ? accountRoot : workspace, fp), account: p.where.startsWith("library/") }));
967
+ if (scan) {
968
+ const { readTree } = await core();
969
+ const files = readTree(workspace);
970
+ // One ignore syntax for every rule: check's own findings honour the
971
+ // same `foldrun-ignore <rule>: <reason>` comments the scanner does.
972
+ if (scan.filterIgnored) findings = [...scan.filterIgnored(findings.filter((f) => !f.account), files), ...findings.filter((f) => f.account)];
973
+ let accountNetwork;
974
+ try {
975
+ const md = path.join(accountRoot, "AGENTS.md");
976
+ if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8")).data?.network;
977
+ } catch {
978
+ // an unreadable account AGENTS.md is reported by its own check
979
+ }
980
+ for (const f of scan.scanFiles(files, { outwardTools, accountNetwork })) {
981
+ findings.push({ ...f, where: `${f.file}${f.line ? `:${f.line}` : ""}` });
982
+ }
983
+ }
984
+ findings = findings.map((f) => {
985
+ if (f.account) return f;
986
+ const file = sourcePathOf(workspace, f.file);
987
+ return file === f.file ? f : { ...f, file, where: `${file}${f.line ? `:${f.line}` : ""}` };
988
+ });
989
+ const r = narrowCheck(findings, workspace, opts, workspace, accountRoot);
990
+ return { ...r, summary, noScanner: !scan };
991
+ }
992
+
993
+ /** --rule / --security, then --changed, then the baseline (or writing one).
994
+ * `accountRoot` is where findings marked `account: true` live. */
995
+ function narrowCheck(findings, root, opts, label, accountRoot = root) {
996
+ let out = findings;
997
+ if (opts.rules.length) {
998
+ const want = new Set(opts.rules);
999
+ out = out.filter((f) => want.has(f.rule) || want.has(f.rule.split("/")[0]) || want.has(categoryOf(f.rule)));
1000
+ }
1001
+ if (opts.security) out = out.filter((f) => SECURITY_CATEGORIES.has(categoryOf(f.rule)));
1002
+ // check's own skill rule already says a skill has no description, as an
1003
+ // error; the schema's note would say it twice. Only when that error is
1004
+ // still in the report — `--rule config/missing-field` keeps the note.
1005
+ out = out.filter((f) => !(f.rule === "config/missing-field" && out.some((g) => g.file === f.file && g.rule === "skill/config" && /no description|needs name and description/.test(g.message))));
1006
+ const file = path.join(root, ".foldrun", "check-baseline.json");
1007
+ if (opts.writeBaseline) {
1008
+ const created = new Date().toISOString();
1009
+ const seen = new Map(out.map((f) => [`${f.rule}\0${f.file}\0${f.fingerprint}`, { rule: f.rule, file: f.file, fingerprint: f.fingerprint }]));
1010
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1011
+ fs.writeFileSync(file, JSON.stringify({ version: 1, created, findings: [...seen.values()].sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule)) }, null, 2) + "\n");
1012
+ return { findings: [], baselined: out.length, wrote: path.relative(process.cwd(), file) || file };
1013
+ }
1014
+ let baselined = 0;
1015
+ if (opts.baseline && fs.existsSync(file)) {
1016
+ let b = null;
1017
+ try {
1018
+ b = JSON.parse(fs.readFileSync(file, "utf8"));
1019
+ } catch {
1020
+ // Unreadable hides nothing: failing open would hide every new finding.
1021
+ out = [...out, { rule: "workspace/config", level: "warn", file: ".foldrun/check-baseline.json", message: "the baseline is not JSON — it hides nothing until it is rewritten (`foldrun check --write-baseline`)", fingerprint: "baseline", where: ".foldrun/check-baseline.json" }];
1022
+ }
1023
+ if (b?.version === 1 && Array.isArray(b.findings)) {
1024
+ // An entry that is not {rule, file, fingerprint} hides nothing.
1025
+ const entries = b.findings.filter((f) => f && typeof f === "object" && typeof f.rule === "string" && typeof f.file === "string" && typeof f.fingerprint === "string");
1026
+ const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
1027
+ const kept = out.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
1028
+ baselined = out.length - kept.length;
1029
+ out = kept;
1030
+ }
1031
+ }
1032
+ if (opts.changed) {
1033
+ const here = changedFiles(root, opts.base, label);
1034
+ // The account may not be the same repository, or have that ref: then
1035
+ // nothing in the library counts as changed, and the workspace's own
1036
+ // comparison still stands.
1037
+ let there = here;
1038
+ if (out.some((f) => f.account) && accountRoot !== root) {
1039
+ try {
1040
+ there = changedFiles(accountRoot, opts.base, "library");
1041
+ } catch {
1042
+ there = new Set();
1043
+ }
1044
+ }
1045
+ out = out.filter((f) => {
1046
+ const changed = f.account ? there : here;
1047
+ return changed.has(f.file) || [...changed].some((c) => c.startsWith(`${f.file}/`));
1048
+ });
1049
+ }
1050
+ return { findings: out, baselined };
1051
+ }
1052
+
1053
+ /** Files changed against a ref, tracked or not, relative to `root`. */
1054
+ function changedFiles(root, base, label) {
1055
+ const git = (args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
1056
+ try {
1057
+ // -z: literal paths. Without it git quotes and escapes a name outside
1058
+ // ASCII ("knowledge/\346\227\245.md"), which matches no finding.
1059
+ const diff = git(["diff", "-z", "--name-only", "--relative", base]);
1060
+ const untracked = git(["ls-files", "-z", "--others", "--exclude-standard"]);
1061
+ return new Set([...diff.split("\0"), ...untracked.split("\0")].filter(Boolean));
1062
+ } catch (err) {
1063
+ const why = String(/** @type {any} */ (err).stderr ?? err).trim().split("\n")[0];
1064
+ throw new Error(`--changed needs ${label === "library" ? "the account" : "this workspace"} in a git repository and a ref git knows (${base}) — ${why}`);
1065
+ }
1066
+ }
1067
+
1068
+ function checkExit(findings, opts) {
1069
+ const bar = FAIL_ON[opts.failOn];
1070
+ return findings.some((f) => RANK[f.level] >= bar) ? 1 : 0;
1071
+ }
1072
+
1073
+ /** JSON or SARIF, to stdout or --out. */
1074
+ function emitCheck(findings, opts, { root, summary, baselined }) {
1075
+ const clean = findings.map(({ where, account, ...f }) => f);
1076
+ let body;
1077
+ if (opts.format === "sarif") {
1078
+ body = scanRules?.toSarif
1079
+ ? scanRules.toSarif(clean, { version: CLI_VERSION })
1080
+ : { version: "2.1.0", runs: [{ tool: { driver: { name: "foldrun check", version: CLI_VERSION, rules: [] } }, results: [] }], error: "this @foldrun/core has no scanner — update foldrun for SARIF" };
1081
+ } else {
1082
+ const counts = { error: 0, warn: 0, info: 0 };
1083
+ for (const f of clean) counts[f.level]++;
1084
+ body = { version: 1, tool: `foldrun ${CLI_VERSION}`, root, summary, counts, baselined, findings: clean };
1085
+ }
1086
+ const text = JSON.stringify(body, null, 2) + "\n";
1087
+ if (opts.out) fs.writeFileSync(opts.out, text);
1088
+ else process.stdout.write(text);
1089
+ }
1090
+
1091
+ /** `foldrun check --rules`: every rule check knows, with its level and why. */
1092
+ async function checkRulesCmd(flags) {
1093
+ const scan = await scanCore();
1094
+ const rules = [
1095
+ ...(scan?.SCAN_RULES ?? []).map((r) => ({ ...r, source: "scanner" })),
1096
+ ...(scan?.CHECK_RULES ?? []).map((r) => ({ ...r, source: "check" })),
1097
+ ];
1098
+ if (flags.json === true || flags.format === "json") {
1099
+ process.stdout.write(JSON.stringify({ rules }, null, 2) + "\n");
1100
+ return 0;
1101
+ }
1102
+ console.log("");
1103
+ for (const r of rules) {
1104
+ const lvl = r.source === "check" ? c.dim(" var") : r.level === "warn" ? c.amber(" warn") : r.level === "error" ? c.red("error") : c.dim(" info");
1105
+ console.log(` ${lvl} ${c.bold(r.id.padEnd(28))} ${r.title}${r.owasp?.length ? c.dim(` ${r.owasp.join(" ")}`) : ""}`);
1106
+ }
1107
+ if (!scan) console.log(`\n ${c.amber("!")} this @foldrun/core has no scanner — \`npm i -g foldrun@latest\` adds the secret, security, content and supply-chain rules`);
1108
+ console.log(`\n ${c.dim("var: check's own rules carry the level of each problem. Ignore one with a comment on the line before: foldrun-ignore <rule>: <reason>")}\n`);
1109
+ return 0;
809
1110
  }
810
1111
 
811
1112
  /**
@@ -823,10 +1124,12 @@ async function checkProblems(workspace, flags = {}) {
823
1124
  const T = "default";
824
1125
  const P = "workspace";
825
1126
  const problems = [];
1127
+ await scanCore(); // the rule table checkRuleFor reads
826
1128
  // file:line, like every other linter — so an editor can jump to it and CI
827
- // can annotate the right row.
828
- const note = (level, where, message, line) =>
829
- problems.push({ level, where: line ? `${where}:${line}` : where, message });
1129
+ // can annotate the right row. Every problem carries a rule id, so --rule,
1130
+ // a foldrun-ignore, a baseline and SARIF can all name it.
1131
+ const note = (level, where, message, line, kind) =>
1132
+ problems.push({ level, where: line ? `${where}:${line}` : where, message, rule: checkRuleFor(where, kind) });
830
1133
 
831
1134
  const agents = listAgents(T, P);
832
1135
  const flows = listFlows(T, P);
@@ -1113,7 +1416,7 @@ async function checkProblems(workspace, flags = {}) {
1113
1416
  }
1114
1417
  // A spelling hint (../../storage → workspace/storage) is info, not a warning:
1115
1418
  // the old path works, and a check that cries wolf gets ignored.
1116
- for (const w of lintFlow(f, { agents: [...agentNames], outwardAgents })) note(w.level ?? (w.kind === "spelling" ? "info" : "warn"), `flows/${f.file}`, w.message, w.line);
1419
+ for (const w of lintFlow(f, { agents: [...agentNames], outwardAgents })) note(w.level ?? (w.kind === "spelling" ? "info" : "warn"), `flows/${f.file}`, w.message, w.line, w.kind);
1117
1420
  }
1118
1421
 
1119
1422
  for (const e of evals) {
@@ -1176,7 +1479,10 @@ async function checkProblems(workspace, flags = {}) {
1176
1479
  }
1177
1480
 
1178
1481
  const summary = `${agentNames.size} agents · ${flows.length} flows · ${evals.length} evals · ${Object.keys(tools).length} tools`;
1179
- return { problems, summary };
1482
+ // The scanner needs to know which tools act outside the workspace, across
1483
+ // the library too — resolved here already, so it is handed over.
1484
+ const outwardTools = Object.entries(usable).filter(([, d]) => /** @type {any} */ (d)?.outward === true).map(([n]) => n);
1485
+ return { problems, summary, outwardTools };
1180
1486
  }
1181
1487
 
1182
1488
  /**
@@ -6267,11 +6573,14 @@ async function checkRemote(flags, layout) {
6267
6573
  fs.mkdirSync(path.dirname(abs), { recursive: true });
6268
6574
  fs.writeFileSync(abs, content);
6269
6575
  }
6270
- console.log(`\n ${c.bold(ws)} ${c.dim(`the deployed copy on ${url} · ${files.size} file${files.size === 1 ? "" : "s"}`)}`);
6576
+ // JSON and SARIF own stdout; the framing goes to stderr then.
6577
+ const say = checkOptions(flags).format === "text" ? console.log : console.error;
6578
+ say(`\n ${c.bold(ws)} ${c.dim(`the deployed copy on ${url} · ${files.size} file${files.size === 1 ? "" : "s"}`)}`);
6271
6579
  process.env.FOLDRUN_WORKSPACE = dir;
6272
6580
  process.env.FOLDRUN_ACCOUNT = root;
6273
- const code = await check(dir, flags);
6274
- console.log(` ${c.dim(`checked what is deployed, not this folder — ${ws} · ${url}`)}\n`);
6581
+ // The baseline belongs to the folder it was written in, not to a temp copy.
6582
+ const code = await check(dir, { ...flags, "no-baseline": true });
6583
+ say(` ${c.dim(`checked what is deployed, not this folder — ${ws} · ${url}`)}\n`);
6275
6584
  return code;
6276
6585
  } finally {
6277
6586
  if (before.workspace === undefined) delete process.env.FOLDRUN_WORKSPACE;
@@ -10048,8 +10357,11 @@ export async function run(command, positional, flags, workspace, layout) {
10048
10357
  // rules: write them, once, and say so — what `next dev` does.
10049
10358
  if (layout.kind !== "empty" && codingAgent() && !hasCurrentAgentRules(layout.accountRoot)) {
10050
10359
  const r = writeAgentFiles(layout.accountRoot);
10051
- console.log(` ${c.dim(`coding-agent rules written for ${codingAgent()} — AGENTS.md ${r.agentsMd}, CLAUDE.md ${r.claudeMd}; read \`foldrun docs\` before editing`)}`);
10360
+ // JSON and SARIF own stdout; a note there would break the parse.
10361
+ const say = flags.json === true || (typeof flags.format === "string" && flags.format !== "text") ? console.error : console.log;
10362
+ say(` ${c.dim(`coding-agent rules written for ${codingAgent()} — AGENTS.md ${r.agentsMd}, CLAUDE.md ${r.claudeMd}; read \`foldrun docs\` before editing`)}`);
10052
10363
  }
10364
+ if (flags.rules === true) return checkRulesCmd(flags);
10053
10365
  // --to names a workspace on a PLATFORM, so there is nothing local to
10054
10366
  // check and the folder this terminal stands in is beside the point.
10055
10367
  return typeof flags.to === "string"
package/src/guide.mjs CHANGED
@@ -30,7 +30,7 @@ export function agentRulesBlock() {
30
30
 
31
31
  # Read foldrun's docs before you edit this folder
32
32
 
33
- This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
33
+ This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls; it is also the security scanner (a key written into a file, hidden text, an agent that can leak), and \`foldrun check --security\` fails on what it finds. Never paste a credential into a file: \`foldrun secrets set NAME\` and write \`\${NAME}\`. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
34
34
 
35
35
  This block is for coding tools. It is written and re-added by the foldrun CLI, and the runtime strips it, so the agents that run here never see it; the rest of this file is the account's own context, which they do read. Committing the block with your work keeps the tree clean.
36
36