-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocs.html
More file actions
3602 lines (3479 loc) · 197 KB
/
Copy pathdocs.html
File metadata and controls
3602 lines (3479 loc) · 197 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>frost 0.10.0, language documentation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--paper:#EAF0F4;
--card:#FFFFFF;
--ink:#0D1418;
--muted:#5B6C77;
--frost:#14657F;
--frost-deep:#0B3F52;
--ice:#CBDFE9;
--ice-soft:#E2EDF3;
--ember:#A6401B;
--rule:#BFD3DE;
--display:"Space Grotesk",system-ui,sans-serif;
--body:"Newsreader",Georgia,serif;
--mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:1.5rem}
body{
margin:0;background:var(--paper);color:var(--ink);
font-family:var(--body);font-size:17.5px;line-height:1.65;
-webkit-font-smoothing:antialiased;
}
/* ---------------------------------------------------------- structure */
.shell{display:grid;grid-template-columns:255px minmax(0,1fr);gap:0;
max-width:1240px;margin:0 auto;}
aside{
position:sticky;top:0;height:100vh;overflow-y:auto;
padding:2.2rem 1.4rem 3rem 1.6rem;
border-right:1px solid var(--rule);
}
.brand{font-family:var(--display);font-weight:700;font-size:1.45rem;
letter-spacing:-.03em;line-height:1;margin:0 0 .1rem;}
.brand span{color:var(--frost)}
.brand-sub{font-family:var(--mono);font-size:.68rem;letter-spacing:.13em;
text-transform:uppercase;color:var(--muted);margin:0 0 1.8rem}
.nav-title{font-family:var(--mono);font-size:.66rem;letter-spacing:.15em;
text-transform:uppercase;color:var(--frost);margin:1.5rem 0 .5rem;
padding-bottom:.35rem;border-bottom:1px solid var(--rule);}
aside ul{list-style:none;margin:0;padding:0}
aside li a{
display:block;padding:.2rem 0;color:var(--muted);text-decoration:none;
font-family:var(--display);font-size:.83rem;font-weight:500;
line-height:1.35;border-left:2px solid transparent;padding-left:.65rem;
margin-left:-.65rem;transition:color .15s,border-color .15s;
}
aside li a:hover{color:var(--ink)}
aside li a.here{color:var(--frost-deep);border-left-color:var(--frost)}
main{padding:2.2rem 3.2rem 8rem;min-width:0}
.doc{max-width:74ch}
/* ------------------------------------------------------------- hero */
.hero{margin:0 0 3.5rem}
.eyebrow{font-family:var(--mono);font-size:.7rem;letter-spacing:.16em;
text-transform:uppercase;color:var(--frost);margin:0 0 .9rem}
.hero h1{font-family:var(--display);font-weight:700;font-size:clamp(2.1rem,5vw,3.1rem);
letter-spacing:-.035em;line-height:1.02;margin:0 0 .8rem}
.hero .lede{font-size:1.18rem;color:var(--muted);margin:0 0 2.2rem;max-width:52ch}
/* signature: the nested chunk expression, decomposed */
.thesis{
background:var(--card);border:1px solid var(--rule);
border-radius:3px;padding:1.7rem 1.6rem 1.4rem;
}
.thesis-label{font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;
text-transform:uppercase;color:var(--muted);margin:0 0 1.1rem}
.expr{font-family:var(--mono);font-size:clamp(.85rem,2.1vw,1.05rem);
line-height:2.5;white-space:nowrap;overflow-x:auto;padding-bottom:.3rem}
.layer{
padding:.3em .45em;border-radius:2px;cursor:default;
transition:background .18s ease, color .18s ease;
border-bottom:2px solid transparent;
}
.layer[data-on="1"]{background:var(--ice-soft);border-bottom-color:var(--ice)}
.layer[data-on="2"]{background:var(--ice);border-bottom-color:#9FC3D4}
.layer[data-on="3"]{background:var(--frost);color:#fff;border-bottom-color:var(--frost-deep)}
.sample{
margin-top:1.2rem;padding-top:1.1rem;border-top:1px solid var(--rule);
font-family:var(--mono);font-size:.76rem;line-height:1.95;color:var(--muted);
white-space:pre;overflow-x:auto;
}
.sample b{font-weight:400;color:var(--muted)}
.sample .row.lit{color:var(--ink)}
.sample .row.lit .hit{background:var(--frost);color:#fff;padding:.1em .25em;border-radius:2px}
.thesis-note{margin:1.1rem 0 0;font-size:.9rem;color:var(--muted)}
.thesis-note code{background:none;border:none;padding:0;color:var(--ember);font-size:.85rem}
/* ---------------------------------------------------------- typography */
main h2{
font-family:var(--display);font-weight:700;font-size:1.62rem;
letter-spacing:-.022em;line-height:1.15;
margin:3.6rem 0 1rem;padding-top:1.5rem;border-top:1px solid var(--rule);
}
main h3{font-family:var(--display);font-weight:500;font-size:1.13rem;
letter-spacing:-.01em;margin:2.2rem 0 .6rem}
main p{margin:0 0 1.05rem}
main strong{font-weight:600}
main ul,main ol{margin:0 0 1.15rem;padding-left:1.25rem}
main li{margin-bottom:.42rem}
main hr{display:none}
a{color:var(--frost);text-underline-offset:3px}
/* inline code */
main p code,main li code,main td code{
font-family:var(--mono);font-size:.83em;background:var(--ice-soft);
border:1px solid var(--ice);border-radius:2px;padding:.08em .34em;
color:var(--frost-deep);
}
/* code blocks */
pre{
background:var(--card);border:1px solid var(--rule);border-left:3px solid var(--frost);
border-radius:2px;padding:1.05rem 1.15rem;margin:0 0 1.35rem;
overflow-x:auto;font-size:.83rem;line-height:1.72;
}
pre code{font-family:var(--mono);background:none;border:none;padding:0;color:var(--ink)}
pre.shell{border-left-color:var(--muted);background:#0D1418;color:#C6D6DE}
pre.shell code{color:#C6D6DE}
pre.diagnostic{border-left-color:var(--ember);background:#FBF2EE}
pre.grammar{border-left-color:var(--ice);font-size:.78rem}
pre.transcript{border-left-color:var(--muted);background:#F5F8FA;color:#33454F}
pre.transcript code{color:#33454F}
pre.policy{border-left-color:#8A6212;background:#FAF6EC}
/* frost syntax colours */
.tk-kw{color:var(--frost);font-weight:600}
.tk-noun{color:var(--frost-deep)}
.tk-str{color:var(--ember)}
.tk-num{color:#6B3FA0}
.tk-com{color:#8A9AA5;font-style:italic}
/* tables */
table{border-collapse:collapse;width:100%;margin:0 0 1.4rem;font-size:.92rem}
th,td{text-align:left;padding:.5rem .7rem;border-bottom:1px solid var(--rule);
vertical-align:top}
th{font-family:var(--display);font-weight:500;font-size:.78rem;
letter-spacing:.05em;text-transform:uppercase;color:var(--frost);
border-bottom:1px solid var(--frost)}
tbody tr:last-child td{border-bottom:none}
/* ------------------------------------------------------------ mobile */
.menu-btn{display:none}
@media (max-width:860px){
.shell{grid-template-columns:1fr}
aside{position:static;height:auto;border-right:none;
border-bottom:1px solid var(--rule);padding:1.4rem 1.4rem 1.8rem}
aside .nav-body{display:none}
aside.open .nav-body{display:block}
.menu-btn{display:block;font-family:var(--mono);font-size:.72rem;
letter-spacing:.12em;text-transform:uppercase;background:none;
border:1px solid var(--rule);border-radius:2px;padding:.4rem .7rem;
color:var(--frost);cursor:pointer}
main{padding:1.8rem 1.3rem 5rem}
.expr{white-space:normal;line-height:2.7}
}
@media (prefers-reduced-motion:reduce){
*{transition:none!important;animation:none!important}
html{scroll-behavior:auto}
}
.layer{animation:settle .5s ease both}
@keyframes settle{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
</style>
</head>
<body>
<div class="shell">
<aside id="side">
<p class="brand">fr<span>o</span>st</p>
<p class="brand-sub">v0.10.0 · language docs</p>
<button class="menu-btn" id="menu">Contents</button>
<div class="nav-body"><p class="nav-title">Guide</p><ul><li><a href="#guide-what-you-get">What you get</a></li><li><a href="#guide-three-things-it-fixes">Three things it fixes</a></li><li><a href="#guide-why">Why</a></li><li><a href="#guide-side-by-side">Side by side</a></li><li><a href="#guide-patterns-and-timeouts">Patterns and timeouts</a></li><li><a href="#guide-cleanup-that-actually-runs">Cleanup that actually runs</a></li><li><a href="#guide-lists-without-a-second-grammar">Lists, without a second grammar</a></li><li><a href="#guide-structured-data-without-a-second-grammar">Structured data, without a second grammar</a></li><li><a href="#guide-knowing-why-it-failed">Knowing why it failed</a></li><li><a href="#guide-a-clock-that-replays">A clock that replays</a></li><li><a href="#guide-secrets-that-cannot-be-logged-by-accident">Secrets that cannot be logged by accident</a></li><li><a href="#guide-knowing-what-it-did-not-just-what-it-could-do">Knowing what it did, not just what it could do</a></li><li><a href="#guide-telling-a-monitoring-system-what-happened">Telling a monitoring system what happened</a></li><li><a href="#guide-why-this-matters-for-ai-agents">Why this matters for AI agents</a></li><li><a href="#guide-the-feature-that-carries-the-language">The feature that carries the language</a></li><li><a href="#guide-a-script-you-can-check-before-you-run-it">A script you can check before you run it</a></li><li><a href="#guide-install">Install</a></li><li><a href="#guide-try-it-in-30-seconds">Try it in 30 seconds</a></li><li><a href="#guide-reviewing">Reviewing</a></li><li><a href="#guide-starting-a-project">Starting a project</a></li><li><a href="#guide-in-a-pipeline">In a pipeline</a></li><li><a href="#guide-giving-it-to-an-agent">Giving it to an agent</a></li><li><a href="#guide-tooling">Tooling</a></li><li><a href="#guide-usage">Usage</a></li><li><a href="#guide-layout">Layout</a></li><li><a href="#guide-status">Status</a></li><li><a href="#guide-the-name">The name</a></li><li><a href="#guide-license">License</a></li></ul><p class="nav-title">Reference</p><ul><li><a href="#ref-1-design-rules">1. Design rules</a></li><li><a href="#ref-2-lexical-structure">2. Lexical structure</a></li><li><a href="#ref-3-values">3. Values</a></li><li><a href="#ref-4-variables-and-names">4. Variables and names</a></li><li><a href="#ref-5-chunk-expressions">5. Chunk expressions</a></li><li><a href="#ref-5a-lists">5a. Lists</a></li><li><a href="#ref-5b-functions">5b. Functions</a></li><li><a href="#ref-6-operators">6. Operators</a></li><li><a href="#ref-6a-patterns">6a. Patterns</a></li><li><a href="#ref-7-statements">7. Statements</a></li><li><a href="#ref-8-running-programs">8. Running programs</a></li><li><a href="#ref-9-pipes">9. Pipes</a></li><li><a href="#ref-10-failure-handling">10. Failure handling</a></li><li><a href="#ref-10a-cleanup">10a. Cleanup</a></li><li><a href="#ref-11-files">11. Files</a></li><li><a href="#ref-12-handlers">12. Handlers</a></li><li><a href="#ref-12a-modules">12a. Modules</a></li><li><a href="#ref-13-special-values">13. Special values</a></li><li><a href="#ref-13a-secrets">13a. Secrets</a></li><li><a href="#ref-13b-talking-to-the-thing-that-wrote-the-script">13b. Talking to the thing that wrote the script</a></li><li><a href="#ref-13c-recording-and-replaying-a-run">13c. Recording and replaying a run</a></li><li><a href="#ref-14-grammar">14. Grammar</a></li><li><a href="#ref-14a-reading-a-script-before-you-run-it">14a. Reading a script before you run it</a></li><li><a href="#ref-14aa-bounding-how-much-a-run-moves">14aa. Bounding how much a run moves</a></li><li><a href="#ref-14ab-interpreters-reached-through-another-program">14ab. Interpreters reached through another program</a></li><li><a href="#ref-14b-frost-diff">14b. frost diff</a></li><li><a href="#ref-14c-when-a-rule-refuses">14c. When a rule refuses</a></li><li><a href="#ref-15-deliberate-omissions">15. Deliberate omissions</a></li></ul></div>
</aside>
<main>
<header class="hero">
<p class="eyebrow">A scripting language for readable shell scripts</p>
<h1>Scripts are written once<br>and read at 3am.</h1>
<p class="lede">A shell scripting language for the era when machines write
the scripts and humans only get to review them: readable by default,
structurally immune to injection, and auditable before a single process
starts.</p>
<p class="lede" style="font-size:1rem"><a href="audit.html">See a live audit report →</a></p>
<div class="thesis">
<p class="thesis-label">Chunk expressions, hover a layer</p>
<div class="expr" id="expr">
<span class="layer" data-depth="1">the third word of<span class="layer" data-depth="2"> line 7 of<span class="layer" data-depth="3"> file "access.log"</span></span></span>
</div>
<div class="sample" id="sample"></div>
<p class="thesis-note">In bash: <code>sed -n '7p' access.log | awk '{print $3}'</code>, two tool dialects instead of one grammar.</p>
</div>
</header>
<div class="doc"><p><a href="https://pypi.org/project/frostlang/"><img alt="PyPI" src="https://img.shields.io/pypi/v/frostlang" /></a>
<a href="https://github.com/keithadler/frost/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/keithadler/frost/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://www.python.org/downloads/"><img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue" /></a>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green" /></a></p>
<p><strong>A shell scripting language for the era when machines write the scripts and
humans only get to review them: readable by default, structurally immune to
injection, and auditable before a single process starts.</strong></p>
<p>A grammar descended from HyperTalk, an interpreter rather than a login shell.</p>
<h2 id="guide-what-you-get">What you get</h2>
<p>Everything below follows from one decision: <strong>a frost program is a parse tree,
never a string.</strong> Nothing is interpolated, nothing is re-parsed, and no value
can become syntax. That single property is what lets a script be checked as a
contract instead of trusted as a guess.</p>
<table>
<thead>
<tr>
<th>Capability</th>
<th>What it buys you</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>No interpolation, no <code>eval</code></strong></td>
<td>Injection is <em>unrepresentable</em>, not mitigated. Hostile text stays text wherever it came from: a filename, an issue title, a web page an agent just read.</td>
</tr>
<tr>
<td><strong><code>--explain</code></strong></td>
<td>A capability manifest before anything runs: every program spawned, file read or written, secret released. Approving capabilities takes seconds; deriving them by reading code takes minutes, and that is where mistakes happen.</td>
</tr>
<tr>
<td><strong><code>--policy</code></strong></td>
<td>Business rules checked against the tree. Not just <em>may it use curl</em>, but how many times, for how long, and whether it cleans up after itself. And every rule explains itself when it fires. Violations exit 3 and the script never starts.</td>
</tr>
<tr>
<td><strong>Sealed secrets</strong></td>
<td>A value from the role-gated keystore cannot be printed by accident. The seal survives concatenation, comparison is constant-time, and <code>--explain</code> names every place a secret is released to a program.</td>
</tr>
<tr>
<td><strong>Modules</strong></td>
<td>An import declares a capability ceiling, so reading the entry file gives a sound upper bound on the whole program. A shared module that later grows a network call breaks the build at the import site rather than quietly widening someone's manifest.</td>
</tr>
<tr>
<td><strong><code>--sandbox</code></strong></td>
<td>The kernel holds the boundary while the script runs, so a path the analyser <em>could not</em> resolve is confined anyway. Fails closed: where the boundary cannot be enforced, frost refuses to run rather than warning and continuing.</td>
</tr>
<tr>
<td><strong><code>the run id</code></strong></td>
<td>One identity per execution, supplied by the pipeline or generated. It reaches the recording, the trace and every child process, so a log line three layers down joins back to the run that caused it. Also an idempotency key, and a scratch path that cannot collide.</td>
</tr>
<tr>
<td><strong><code>--record</code> / <code>--replay</code></strong></td>
<td>Snapshot testing for shell scripts. A recording is a fixture you can commit; replay spawns no process, writes no file, and reports a divergence rather than a stack trace. Secret values are never written down.</td>
</tr>
<tr>
<td><strong>Records and JSON</strong></td>
<td><code>the "name" of the "user" of report</code>: API responses without a second language in the file. Shelling out to <code>jq</code> handed the auditor a string it could not see into; a record is part of the tree.</td>
</tr>
<tr>
<td><strong><code>the error output</code></strong></td>
<td>Why a command failed, not just that it did, without <code>sh -c "... 2>&1"</code>, which is the one construct the auditor flags and the spec forbids.</td>
</tr>
<tr>
<td><strong>Declared record shapes</strong></td>
<td><code>with fields "status", "number"</code> makes a mistyped field a <code>--check</code> failure instead of a silent empty, and verifies the payload at the line that parsed it.</td>
</tr>
<tr>
<td><strong><code>--events</code></strong></td>
<td>NDJSON for Splunk, New Relic, Datadog or a collector. Every command timed, every effect reported, secrets redacted. The finish event says which approved capabilities went <strong>unused</strong>, which is a signal a shell cannot produce and which drives tightening an approval before it is abused.</td>
</tr>
<tr>
<td><strong>Dead code</strong></td>
<td>Unreachable statements, handlers nobody calls, values computed and dropped. Harmless individually, and together the clearest sign a generated script contains more than anyone intended.</td>
</tr>
<tr>
<td><strong><code>frost mcp</code></strong></td>
<td>The review tools over Model Context Protocol, stdio JSON-RPC, no dependencies. It cannot run a script and reads no files, by design: frost exists because the decision to run sits with a person, and a server that executes on request moves it back to the machine.</td>
</tr>
<tr>
<td><strong><code>frost context</code></strong></td>
<td>What a model should read before writing frost: the forms, the reserved words taken from the parser, and the constructs it deliberately lacks. Every snippet in it is parsed by the test suite, so it cannot teach a form that does not work.</td>
</tr>
<tr>
<td><strong>Volume limits</strong></td>
<td><code>require at most 10 megabytes of output</code> and <code>--max-output 10MB</code>. A deadline says nothing about a command that answers instantly with a gigabyte. The child is killed at the ceiling rather than measured after the fact, because a limit that notices afterwards prevents nothing.</td>
</tr>
<tr>
<td><strong>Nested interpreters</strong></td>
<td><code>xargs sh -c</code>, <code>env sh -c</code>, <code>sudo sh -c</code>, <code>find -exec</code>, <code>ssh host "..."</code>. The escape check used to fire only when the interpreter was the program name, so every indirect form reported nothing at all. A manifest may overstate; understating is what makes it worse than none.</td>
</tr>
<tr>
<td><strong><code>frost diff</code></strong></td>
<td><code>frost diff old.frost new.frost</code> compares two versions by what they can do, not by their text. Three rearranged lines can be a widening and thirty can be a rename, so a review that reads the text diff is reading the wrong artefact.</td>
</tr>
<tr>
<td><strong>Output masking</strong></td>
<td>A program handed a credential often echoes it back. frost finds the plaintext in what a child wrote and re-seals it, so it redacts wherever the script prints it. Exact-match only: a mask that guesses at shapes fails in both directions and gets trusted for the one it fails at quietly.</td>
</tr>
<tr>
<td><strong>Repair report</strong></td>
<td>A refusal names the narrowest policy change that would clear it, and states what else that change would allow. It is never a patch, and under <code>--automated</code> it declines to answer: an agent handed the exact edit that clears its own refusal has been handed the instructions for widening its own bounds.</td>
</tr>
<tr>
<td><strong>Environment rules</strong></td>
<td><code>forbid reading the environment "AWS_*"</code> and <code>require reading only the environment "PATH"</code>. Setting had a rule and reading did not, which was the wrong way round.</td>
</tr>
<tr>
<td><strong><code>--deadline</code></strong></td>
<td>A budget for the whole run, honoured with cleanup and exiting 124. A loop doing arithmetic has no capabilities, so the manifest called it clean; an unbounded loop is now a finding, and a policy can impose the budget centrally.</td>
</tr>
<tr>
<td><strong>Site policy</strong></td>
<td><code>/etc/frost/policy.d/*.policy</code> applies to every run on the host, whether or not anyone passed <code>--policy</code>. Site rules add to a project's and can only narrow them, and every policy applied is named by digest in the manifest and the recording.</td>
</tr>
<tr>
<td><strong><code>--automated</code></strong></td>
<td>An unattended run refuses <code>--approve</code> and <code>--ignore-approval</code>. A repair loop that can approve is one that approves its own capability escalation.</td>
</tr>
<tr>
<td><strong>Signed approvals</strong></td>
<td><code>--sign-with</code> binds an approval to a named approver and a commit; <code>require an approval signed by "..."</code> names who a host trusts. Verification never degrades: without the cipher, an unverifiable signature is refused.</td>
</tr>
<tr>
<td><strong><code>--approve</code></strong></td>
<td>Records what a script does today, then binds by default: a regeneration that does more is refused without any flag. A content hash fires on every edit, so it cannot be used on a script an agent rewrites. This fires only when the script gained a capability.</td>
</tr>
<tr>
<td><strong>SARIF and an Action</strong></td>
<td><code>--check --sarif</code> feeds GitHub code scanning, so a refusal appears on the diff line in front of the person merging rather than in a log nobody opens. <code>action.yml</code> wires check, explain, policy and approvals into six lines of workflow.</td>
</tr>
<tr>
<td><strong><code>--policy-from</code></strong></td>
<td>Writes a starter policy describing what a script already does. The policy engine was the most useful thing here and the least used, because the first step was a blank file.</td>
</tr>
<tr>
<td><strong><code>--json</code> / <code>--repair</code></strong></td>
<td>Every diagnostic as structured data with the edit attached, so the model that wrote the script can repair it without a human in the loop.</td>
</tr>
</tbody>
</table>
<h3 id="guide-the-lifecycle">The lifecycle</h3>
<p>Each stage is optional, and each one narrows what the next has to trust:</p>
<pre><code class="language-text">frost --check s.frost does it parse, and are the names real?
frost --explain s.frost what could it possibly do?
frost --policy p s.frost is that allowed here?
frost --sandbox s.frost hold the boundary while it runs
frost --record r s.frost write down what it actually did
frost s.frost refuse it if it gained a capability
frost --events e s.frost tell a monitoring system what happened
</code></pre>
<p>The first three cost about a millisecond and all happen before a single
process starts. The fourth is enforced by the operating system. The fifth
turns a run into a fixture.</p>
<h3 id="guide-where-the-rest-is-documented">Where the rest is documented</h3>
<ul>
<li><strong><a href="LANGUAGE.md">LANGUAGE.md</a></strong>: the full reference and grammar.</li>
<li><strong><a href="MODEL-SPEC.md">MODEL-SPEC.md</a></strong>: a prompt-sized spec, built to be pasted
into a system prompt so a model writes correct frost first time.</li>
<li><strong><a href="CHANGELOG.md">CHANGELOG.md</a></strong>: what changed and why.</li>
<li><strong><a href="CONTRIBUTING.md">CONTRIBUTING.md</a></strong>: how the pieces fit together.</li>
<li><strong><a href="PLATFORM.md">PLATFORM.md</a></strong>: for the team that operates the machines:
what frost enforces, what it cannot, and what actually makes any of it
mandatory. The honest answer to that last one lives mostly outside frost.</li>
<li><strong><a href="https://keithadler.github.io/frost/play.html">Try it in the browser</a></strong>,
the scratchpad, and below it frost itself compiled to WebAssembly. The same
Python the command line runs, so <code>--explain</code>, <code>--policy</code>, <code>--repair</code> and an
approval comparison answer exactly as they would in a terminal. Nothing to
install.</li>
<li><strong><a href="https://keithadler.github.io/frost/docs.html">Reference</a></strong> and
<strong><a href="https://keithadler.github.io/frost/audit.html">a visual audit report</a></strong>.
(These are committed as <code>docs.html</code>, <code>audit.html</code> and <code>play.html</code> too, but
GitHub shows a committed page as source rather than rendering it, so the
links above are the ones to follow.)</li>
</ul>
<pre><code>#!/usr/bin/env frost
put the number of lines in file "access.log" into request count
put "processing" && request count && "requests"
pipe
run "grep" with "ERROR", "access.log"
run "awk" with "{print $1}"
run "sort"
run "uniq" with "-c"
end pipe
repeat for each line in it as tally
put the second word of tally && "failed" && the first word of tally && "times"
end repeat
</code></pre>
<h2 id="guide-three-things-it-fixes">Three things it fixes</h2>
<p><strong>Injection is unrepresentable, not mitigated.</strong> There is no interpolation and
no <code>eval</code>. Arguments are a list handed to <code>execve</code>, never re-parsed.</p>
<pre><code class="language-text">$ cat hostile.frost
put "notes.txt; rm -rf *" into evil name
run "touch" with evil name
$ frost hostile.frost && ls
'notes.txt; rm -rf *' keep_me.txt precious.db
$ bash -c "touch $EVIL" && ls
# empty. everything is gone.
</code></pre>
<p><strong>Failure stops the script.</strong> No <code>set -e</code> to forget. <code>run</code> aborts on non-zero
exit; <code>try to run</code> opts out and is greppable in review.</p>
<p><strong>Pipes fail if any stage fails.</strong> <code>cat missing.log | wc -l</code> reports success in
bash. In frost the first failing stage wins, and there is no way to turn that
off.</p>
<h2 id="guide-why">Why</h2>
<p>Shell syntax was optimised for a cost that no longer dominates. <code>cut -d: -f1</code>
is terse because a human typed it a thousand times on a serial terminal. When a
model writes the script and a human reads it once: at 3am, while production is
down: the scarce resource is comprehension at review time, not keystrokes.</p>
<p>frost inverts the optimisation. It is not a login shell; it is an interpreter
you point at a file. Because nobody types it at a prompt, nothing in the design
has to be short.</p>
<p>The speed objection does not apply, though not for the reason you might
expect. <code>python tools/benchmark.py</code> measures it rather than asserting it:</p>
<pre><code class="language-text">release.frost 78 lines parse 664us audit 290us
one fork+exec of true 1992us (the floor)
one fork+exec of git --version 12767us (a real command)
</code></pre>
<p>Parsing an 80-line script and deriving its entire capability manifest costs
about a millisecond. So does starting a process, the two are the same order
of magnitude, and which one wins depends entirely on the machine. <code>fork</code>/
<code>exec</code> of <code>true</code> is roughly 0.7ms on Linux and 2.4ms on macOS; <code>git
--version</code> is 1.2ms on Linux and 12ms on macOS. Parsing varies far less.</p>
<p>The tempting claim, <em>you can parse verbose syntax faster than you can spawn
a process</em>, is therefore true on macOS and false on Linux. It was in this
README until CI on Linux disproved it.</p>
<p>What actually holds is the thing the design relies on: <strong>parsing is paid
once, spawning is paid per command.</strong> A script that runs ten commands spends
ten process spawns against one parse, so the parse is a rounding error no
matter which platform it runs on. Verbosity costs nothing at this scale
because the front end is a fixed cost, not because it wins a race.</p>
<h2 id="guide-side-by-side">Side by side</h2>
<pre><code class="language-bash">#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
LOG="${1:?usage: report LOGFILE}"
[[ -f "$LOG" ]] || { echo "no log at $LOG" >&2; exit 1; }
n=$(wc -l < "$LOG")
errs=$(awk '$4 ~ /^5/' "$LOG" | wc -l)
echo "requests: $n"
echo "errors: $errs"
(( errs > 2 )) && { echo "ALERT" >&2; exit 1; }
</code></pre>
<pre><code>#!/usr/bin/env frost
put item 1 of the arguments into log path
if log path is empty then
put "usage: report <logfile>" into standard error
quit with status 2
end if
if not file (log path) exists then
put "no log at" && log path into standard error
quit with status 1
end if
put the number of lines in file (log path) into request count
put 0 into error count
repeat for each line in file (log path) as this request
if the fourth word of this request starts with "5" then
add 1 to error count
end if
end repeat
put "requests:" && request count
put "errors: " && error count
if error count is greater than 2 then
put "ALERT" into standard error
quit with status 1
end if
</code></pre>
<p>Longer, and that is the trade. Nothing in the second version needs explaining
to someone who has never seen frost: no <code>set -euo pipefail</code>, no <code>IFS</code>
incantation, no <code>${1:?}</code>, no <code>$4 ~ /^5/</code>, no <code>>&2</code>. The safety that bash gets
from three lines of ceremony, frost gets from having no other mode.</p>
<h2 id="guide-patterns-and-timeouts">Patterns and timeouts</h2>
<p>Globs for filenames, regex when you actually need it, and neither pretending to
be the other:</p>
<pre><code>if filename is like "*.tmp" then delete file filename
if request matches "^(\S+) (\w+) (\d+)$" then
put match 1 into client address
put the last match into status code
end if
put every match of "\d+" in request into numbers
replace "(\d+)-(\d+)-(\d+)" with "\3/\2/\1" in date text
</code></pre>
<p>Capture groups use the same chunk grammar as everything else: <code>match 1</code>,
<code>the last match</code>, <code>the number of matches</code>, <code>the whole match</code>.</p>
<p>Any command can carry a deadline, with a required unit:</p>
<pre><code>run "curl" with "--silent", endpoint within 30 seconds
try to pipe within 1 minute
run "find" with "/", "-name", "*.log"
run "xargs" with "wc", "-l"
end pipe
if the result is 124 then put "took too long" into standard error
</code></pre>
<p>Timed-out children are killed and reaped: no orphans, no wedged script.</p>
<h2 id="guide-cleanup-that-actually-runs">Cleanup that actually runs</h2>
<p>Abort-on-failure is the headline default, which makes the way out matter as
much as the way through. <code>ensure</code> registers a block when execution reaches it,
and it runs when the script ends: normally, on error, on <code>quit</code>, or on Ctrl-C
most recent first:</p>
<pre><code>put "held" into file (lock path)
ensure
delete file (lock path)
end ensure
run "make" with "deploy"
</code></pre>
<p>The lock is released whether <code>make</code> succeeds or not. A failure inside a
cleanup block is reported but never replaces the error that ended the script.</p>
<h2 id="guide-lists-without-a-second-grammar">Lists, without a second grammar</h2>
<p>A plural chunk noun with no index is the whole set, so splitting is the
grammar from the previous section read the other way:</p>
<pre><code>put the words of headline into terms
put the lines of report into rows
put item 1 of (passwd entry split by ":") -- cut -d: -f1
put the sorted (the unique terms) joined by ", "
</code></pre>
<p>A list keeps its elements separate, which comma-delimited text cannot, an
element may contain a comma and stay one element. <code>the sorted X</code> compares
numerically when every element is a number, because sorting 10 before 9 is
never what a counter meant.</p>
<p>Text and numbers come with the article, so they cost nothing from the name
vocabulary, <code>sorted count</code> is still a perfectly good variable:</p>
<pre><code>if the lowercase target is "production" then put "shipping for real"
put the trimmed reply into answer
put the sum of the words of counts
</code></pre>
<p>And a handler can be used inside an expression, so composing two of them no
longer needs three statements and a temporary:</p>
<pre><code>to double with n
return n * 2
end double
put the double of 5 + the double of 10 -- 30
</code></pre>
<p>An unknown name there is caught when the script is checked, not when the line
happens to run.</p>
<h2 id="guide-structured-data-without-a-second-grammar">Structured data, without a second grammar</h2>
<p>Every real script eventually calls an API and reads a field out of the answer.
Until now that meant <code>run "jq" with ".status"</code>: a second language inside a
file whose entire argument is that it needs only one, and a string the auditor
could not see into. <code>--explain</code> could tell you the script ran <code>jq</code>. It could
never tell you what for.</p>
<pre><code>run "curl" with "-fsS", "https://api.example.com/build" within 30 seconds
put the json of it into build
if the "status" of build is not "green" then
put "build" && the "number" of build && "failed" into standard error
put the "name" of the "author" of build && "was last to push"
quit with status 1
end if
</code></pre>
<p>Objects become records, arrays become the lists frost already has, and numbers
stay numbers: so <code>item 1 of</code>, <code>repeat for each</code> and <code>+ 1</code> all keep working. A
missing key is empty, like <code>word 99 of</code>, and a field of empty is empty, so an
optional field needs no guard. A field of <em>text</em> is an error, because that
means the value is not the shape the script thinks it is.</p>
<p>Parsing a secret seals every field it produces, and serialising redacts field
by field rather than all at once, a record you cannot print at all is a
record people work around.</p>
<pre><code class="language-text">put the json of the secret file "credentials.json" into config
put "connecting as" && the "user" of config
connecting as «secret credentials.json»
</code></pre>
<h2 id="guide-knowing-why-it-failed">Knowing why it failed</h2>
<p><code>the error output</code> sits beside <code>it</code> and <code>the result</code>: what the last command
wrote to standard error, what it wrote to standard output, and how it exited.</p>
<pre><code>try to run "curl" with "-fsS", url within 30 seconds
if the result is not 0 then
put "curl failed:" && the error output into standard error
quit with status 1
end if
</code></pre>
<p>The alternative was <code>run "sh" with "-c", "... 2>&1"</code>, which reintroduces the
shell frost exists to remove and which the auditor flags on sight. Wanting to
know why something failed is completely ordinary, and it should not require
defeating the language's main guarantee to get it.</p>
<h2 id="guide-a-clock-that-replays">A clock that replays</h2>
<pre><code>put "started at" && the current timestamp
wait 5 seconds
</code></pre>
<p>Both are recorded. <code>--replay</code> serves back the reading that was recorded rather
than reading the clock again, a fixture whose timestamps move on every replay
is a diff generator, not a fixture: and it does not sleep, so replaying a
script that backs off for thirty seconds costs nothing. A script that waits
says so in <code>--explain</code>.</p>
<h2 id="guide-secrets-that-cannot-be-logged-by-accident">Secrets that cannot be logged by accident</h2>
<p>The failure worth designing against is not a malicious script. It is
<code>put "connecting as" && token</code> in a generated script, running in CI, writing a
credential into a log that is retained for a year. That mistake is made by
being ordinary, so the fix has to be structural.</p>
<p>A secret is a <em>sealed</em> value. It refuses to become text, and every printing
path in the language goes through one conversion: so <code>put</code>, joining,
<code>--trace</code> and error messages all redact without knowing secrets exist:</p>
<pre><code>put the secret "db password" into password
put "connecting as" && user && "with" && password
</code></pre>
<pre><code class="language-text">connecting as deploy with «secret db password»
</code></pre>
<p>Only the secret spans redact; the rest of the line survives, because a
mechanism that destroys your logs is one people route around. The seal is
contagious, so <code>"postgres://user:" & password & "@host"</code> is still sealed and
still works when it reaches a program.</p>
<p><strong>Streams redact, boundaries release.</strong> Printing is the accidental path and is
closed. A program's arguments, its standard input, its environment and a file
write are deliberate, so they get the real value, and <code>--explain</code> names every
place it happens:</p>
<pre><code class="language-text">Reads these secrets:
db password at line 4 (from the keystore)
Lets a secret leave the process:
on the standard input of psql at line 9
</code></pre>
<p>Values live in a keystore, and each one names the roles that may read it:</p>
<pre><code class="language-bash">frost keystore set prod.keystore "db password" --roles deploy,admin
frost --keystore prod.keystore --role deploy release.frost
</code></pre>
<p>If the role cannot open a secret the script names, frost exits 3 and nothing
runs. The secret <em>names</em> and the role grants are stored in plaintext, because
that is the part a reviewer needs; only the values are encrypted. Roles hold
X25519 keypairs, so storing a secret and granting a role need no passphrase,
only reading does.</p>
<p>It does not stop a script handing a secret to a program it is allowed to run;
nothing at this layer can. And once the plaintext reaches another program,
frost cannot follow it. The manifest reports the release rather than pretending
otherwise.</p>
<h2 id="guide-knowing-what-it-did-not-just-what-it-could-do">Knowing what it did, not just what it could do</h2>
<p><code>--explain</code> answers what a script <em>can</em> do. What it <em>did</em> needed watching.</p>
<pre><code class="language-bash">frost --record run.json deploy.frost # run it, write down everything
frost --replay run.json deploy.frost # run it again, spawn nothing
</code></pre>
<p>Replay performs nothing at all: no process, no write, no delete, and serves
the recorded answers back. So a recording is a fixture: change the script,
replay it, and a refactor meant to preserve behaviour either did or did not.</p>
<pre><code class="language-text">DIVERGED at deploy.frost:3
the recording ran: echo two
this run wants: echo CHANGED
</code></pre>
<p>Reformatting replays clean, because matching is on the identity of the effect
rather than on line numbers. Secret values are never written down, only their
names: and any revealed plaintext is scrubbed from everything recorded, so
the fixture is safe to commit.</p>
<h2 id="guide-telling-a-monitoring-system-what-happened">Telling a monitoring system what happened</h2>
<pre><code class="language-bash">frost --events run.ndjson deploy.frost # or - for standard error
</code></pre>
<p>One JSON object per line, flushed as things happen. NDJSON is what Splunk's
HTTP collector, New Relic's log API, Datadog, Vector and Fluent Bit all ingest
without a translator, and a line-oriented file survives a run that is killed
halfway, which a single JSON document does not.</p>
<pre><code class="language-json">{"event": "run.start", "declares": {"programs": ["curl"], "hosts": ["x.example"]}}
{"event": "command.start", "program": "curl", "argv": ["curl", "-fsS", "..."]}
{"event": "command.finish", "program": "curl", "status": 0, "seconds": 0.412}
{"event": "run.finish", "status": 0, "commands": 3, "waited_seconds": 2.0,
"programs_unused": ["psql"], "hosts_unused": ["db.internal"]}
</code></pre>
<p><strong>The resolution worth having is the pairing, not the volume.</strong> Any tool can
log that a command ran. frost knows what the script was <em>allowed</em> to do before
it ran, what a person <em>approved</em>, and what the host <em>permits</em>, so the finish
event reports which approved capabilities went <strong>unused</strong>. A script approved
for six programs that uses two is an approval somebody should tighten, and
that is only visible holding the manifest and the run side by side.</p>
<p>Commands are timed, and the run separates time spent working from time spent
waiting, so a slow job can be attributed rather than guessed at.</p>
<p><strong>A refusal is an event</strong>, which is the one a security team most wants and the
easiest to lose. A policy refusal, a breached import ceiling, an approval that
no longer covers the script, an unusable signature, a secret the role may not
read: each closes the run out with what fired and the digest of the policy it
came from.</p>
<pre><code class="language-json">{"event": "run.finish", "status": 3, "refused": "policy",
"rules": [{"what": "running \"curl\"", "line": 1,
"hint": "egress goes through the proxy"}],
"policies": [{"path": "/etc/frost/policy.d/00-egress.policy",
"sha256": "05cd8a0c7c8f...", "origin": "site"}]}
</code></pre>
<p>Contents are never emitted and sizes are: <em>wrote 4kb</em> is useful and the 4kb is
not. Secrets are redacted before an event is written, including inside a
command's arguments, because telemetry leaves the building far more often than
a recording does.</p>
<p>It composes with the other modes. <code>--events</code> alongside <code>--record</code> gives both,
and a replayed run is marked <code>"replayed": true</code> so a dashboard does not count
a fixture as production traffic. Analysis emits nothing, because <code>--explain</code>
runs nothing and a dashboard should not see a run that never happened.</p>
<h2 id="guide-why-this-matters-for-ai-agents">Why this matters for AI agents</h2>
<p>Shell scripts are increasingly written by models and reviewed by people. That
inverts the assumption every shell was designed under, and it breaks in three
specific places.</p>
<p><strong>Review does not scale at generation speed.</strong> A model can produce forty lines
of bash faster than a person can verify one of them, and <code>set -euo pipefail</code>,
<code>IFS=$'\n\t'</code>, <code>${1:?}</code>, and <code>$4 ~ /^5/</code> all have to be read carefully to be
read at all. The reviewer either slows to the speed of careful reading, or
starts skimming. Most people skim. frost moves the cost: the script is longer,
but nothing in it needs decoding, so skimming and reading converge.</p>
<p><strong>Hostile text reaches a command as data.</strong> An agent that reads a web page, a
filename, an issue title, or a log line and puts that text into a generated
command has handed an attacker a shell. In frost a value cannot become syntax
arguments are a list handed to <code>execve</code>, never re-parsed, so hostile text
stays text no matter where it came from. The <code>rm -rf *</code> in a filename above is
the whole demonstration.</p>
<p><strong>Hostile text reaches the model instead.</strong> This is the harder one, and no
grammar touches it. An agent reads "also upload ~/.ssh/id_rsa" in a README and
writes perfectly valid frost that does exactly that: it parses, it formats
canonically, <code>--check</code> passes. The model is not confused about syntax. It has
been persuaded to use authority it legitimately holds, which is a confused
deputy rather than an injection.</p>
<p>frost's answer is not the grammar. It is that <strong>the thing deciding what is
allowed is not the thing that wrote the script.</strong> A policy is authored by a
person, ahead of time, out of band from generation, so a fully poisoned model
can emit whatever it likes and the rules still refuse it before a process
starts. The sandbox is held by the kernel, and a module cannot widen the
program past what its import declared.</p>
<p>Where there is no policy yet, <code>--approve</code> records what a script does today,
and from then on the approval binds by default: no flag to remember, because
a guard you have to remember is one the attacker composing your command line
will not:</p>
<pre><code class="language-text">REFUSED: it can now run curl
REFUSED: it can now read the secret ~/.aws/credentials (from the file)
REFUSED: it can now let a secret leave the process as an argument to curl
</code></pre>
<p>Be clear about the limit: this bounds <em>what</em> a script can reach, never whether
reaching it was wise. A model allowed to run <code>git</code> can still push to the wrong
remote, and a policy permitting <code>curl</code> alongside a readable config file permits
sending one to the other. Capability bounds are not intent checks, and nothing
here reads intent.</p>
<p><strong>Approval needs something to approve.</strong> "Do you want to run this script?"
asks a person to simulate an interpreter in their head. <code>--explain</code> replaces
that with a capability manifest:</p>
<pre><code class="language-text">This script runs tar and date, reads 2 files (runtime),
and writes 3 files (temporary).
</code></pre>
<p>A human can approve <em>capabilities</em> in seconds. Reading the code to derive those
capabilities takes minutes and is where mistakes happen.</p>
<h3 id="guide-the-part-that-is-actually-new">The part that is actually new</h3>
<p>Because a frost script is a parse tree rather than a string, an agent's output
can be checked mechanically <strong>before</strong> anything executes:</p>
<pre><code class="language-policy">forbid running "rm" with "-rf"
forbid writing to "/etc/*"
forbid running "sudo"
require timeout on "curl"
require every command to be checked
</code></pre>
<p>Violations exit 3 and the script never starts. This is sandboxing at the
language level rather than the container level, and it composes with a
container rather than competing with it. The agent proposes; the policy
disposes; the human reads a manifest instead of code.</p>
<p>Rules also count, which is what an organisation's actual rules tend to do,
not <em>may it use curl</em>, but <em>how many times, for how long, and does it clean up
after itself</em>:</p>
<pre><code class="language-policy">require at most 12 commands
require at most 2 files written
require at least 1 cleanup
forbid more than 2 runs of "curl"
forbid any files deleted
require timeout on "*" between 1 and 120 seconds
</code></pre>
<p>Units are reconciled, so a policy written in seconds catches a script written
in minutes. A limit that is exceeded points at the line that crossed it. A
deadline computed at runtime is refused rather than assumed acceptable, on the
same principle as the manifest: say what is unknowable, do not guess it.</p>
<p>Rules say why. A rule's trailing comment is its hint, and frost prints it
when the rule fires, so a refusal explains what to do instead rather than
just saying no:</p>
<pre><code class="language-text">REFUSED: running "sudo"
deploy.frost:1 run "sudo" with "systemctl", "restart", "api"
why: the deploy role already has the permissions it needs
</code></pre>
<p>No new syntax; policy authors already write that comment.</p>
<h3 id="guide-modules-that-cannot-hide-anything">Modules that cannot hide anything</h3>
<p>Sharing handlers across scripts is the feature most likely to break the one
invariant frost depends on. That the tree you audit is the program you run,
and the audit sees all of it. So the goal is not <em>safe modules</em>; it is
<strong>modules that cannot put capability outside the manifest</strong>.</p>
<pre><code>use "lib/db.frost" for the connect, the migrate which may run "psql"
</code></pre>
<p>A module is declarations only: handler definitions and imports, nothing that
runs when it is imported. The path is a literal the parser insists on, and it
resolves relative to the importing file with no search path, nothing above
the entry script's directory, and no registry. Imports name exactly what they
bring in, so a collision is an error rather than one module silently
replacing another's handler.</p>
<p><code>--explain</code> audits the whole closure and says where each capability came
from:</p>
<pre><code class="language-text">lib/db.frost (imported by deploy.frost:1)
Runs these programs:
psql at line 2
</code></pre>
<p>And the <code>which may</code> clause is the part that makes single-file review survive
multi-file code. A module defaults to no capabilities at all. If it does more
than its import declared, the program is refused before anything runs:</p>
<pre><code class="language-text">REFUSED: lib/sneaky.frost may not run curl
The import at deploy.frost:2 allows: nothing but compute.
</code></pre>
<p>A ceiling bounds the whole subtree an import pulls in, not just the file it
names, a module allowed to run <code>psql</code> cannot import a second one that runs
<code>curl</code>. So a reviewer who reads only the entry file has a sound upper bound on
the whole program, and a shared module that later grows a network call breaks
the build at the import site rather than quietly widening someone's manifest.
<code>frost --lock</code> and <code>--frozen</code> pin the bytes.</p>
<h3 id="guide-closing-the-loop-with-the-thing-that-wrote-it">Closing the loop with the thing that wrote it</h3>
<p>Every refusal above is a sentence for a person. <code>--json</code> is the same
information as data, with the edit attached wherever frost already knew it:</p>
<pre><code class="language-json">{"code": "missing-then", "line": 2, "column": 20,
"message": "expected 'then' but found end of line",
"repairs": [{"kind": "replace-line", "line": 2,
"text": "if error count is 0 then", "confidence": "high"}]}
</code></pre>
<p><code>frost --repair --write</code> applies the high-confidence ones and repeats until
nothing certain is left: fixing one error reveals the next, so a single pass
would give up on any script with two mistakes. A pass is kept only if it made
progress, which is what makes it safe to run unattended:</p>
<pre><code class="language-text">repaired deploy.frost (3 change(s))
line 2: an 'if' condition is closed by 'then'
line 3: run takes a program and a list of arguments, never a command line
line 5: a global is written 'the global <name>'
</code></pre>
<p>That is the loop: generate, check, repair, re-check, with a policy deciding
what is acceptable and a manifest a human approves at the end.</p>
<h3 id="guide-boundaries-the-kernel-holds">Boundaries the kernel holds</h3>
<p>Everything above reasons about the text of a script, and is careful to say
when it cannot know something. Once the script runs, an unknowable path is a
real path. So the boundary is declared once and held at runtime:</p>
<pre><code class="language-policy">sandbox may run "git", "make"
sandbox may read "*"
sandbox may write "build/*"
</code></pre>
<pre><code class="language-bash">frost --policy prod.policy --sandbox deploy.frost
</code></pre>
<pre><code class="language-text">sh: /tmp/anywhere-else.txt: Operation not permitted
</code></pre>
<p>Child processes are confined by the operating system, <code>sandbox-exec</code> on
macOS, <code>bubblewrap</code> on Linux, so a path the analyser could not resolve is
confined anyway. frost's own file operations are checked by frost, which is a
weaker guarantee, and the docs keep the two apart rather than blurring them.</p>
<p>Two things it deliberately will not do. <strong>Per-host network rules are refused,
not faked</strong>: macOS filters on addresses and a Linux namespace is
all-or-nothing, so <code>sandbox may reach "api.github.com"</code> is a parse error and
<code>sandbox may reach the network</code> means exactly what it says. And if a boundary
is declared but cannot be enforced here, <strong>frost refuses to run</strong> rather than
warning and continuing, including when the backend is present but a live
self-test shows it not actually confining.</p>
<p>That self-test runs two controls, not one. A forbidden write must be refused
<em>and</em> a permitted write must succeed. Checking only the first is the trap the
feature is most likely to ship with: a sandbox that fails to start blocks the
forbidden write too, so every "is it blocked?" assertion passes and the thing
reports itself healthy while confining nothing. Both backends were caught by
the second control, Linux dying on a network namespace it was not allowed to
enter, macOS naming an unresolved <code>/tmp</code> path the kernel never matches.</p>
<p>Built-in checks catch the classics with no policy at all, <code>curl … | sh</code> is
reported as <em>downloaded code piped into a shell</em>, and a script that reads
<code>~/.ssh/id_rsa</code> and then makes a network call is flagged as <em>secrets read, then
the network is contacted</em>, the shape of data theft. Both are facts about the
tree, not pattern matches on the text, and both hold even when the sensitive
path is assembled at runtime from a variable and a string fragment.</p>
<h3 id="guide-what-this-does-not-do">What this does not do</h3>
<p>Analysis covers literals. If a script builds a program name or path at runtime,
frost reports it as <em>built at runtime</em> rather than guessing, the manifest tells
you that something is unknowable, not what it is. A determined script can still
put itself out of reach that way, and a policy that permits a command permits
its consequences. This narrows the blast radius and makes review tractable; it
is not a sandbox and does not claim to be.</p>
<h2 id="guide-the-feature-that-carries-the-language">The feature that carries the language</h2>
<p>Chunk expressions. One uniform grammar replaces <code>cut</code>, <code>awk '{print $3}'</code>,
<code>sed -n '7p'</code>, <code>head</code>, and <code>tail</code>:</p>
<pre><code>put the third word of line 7 of file "access.log"
put the last item of csv row
put words 2 to 4 of headline
put the number of lines in report
</code></pre>
<p>Text addressing is most of what shell scripting actually is, and this is the
best notation anyone has shipped for it. It was HyperTalk's, and it deserves
another run.</p>
<h2 id="guide-a-script-you-can-check-before-you-run-it">A script you can check before you run it</h2>
<p><strong>Before you run it, you can read exactly what it is allowed to do. Bash
cannot do this.</strong></p>
<p>Because frost is parsed rather than string-substituted, a script's capabilities
are visible in the tree. <code>--explain</code> prints them:</p>
<pre><code class="language-text">Runs these programs:
rm at line 8 (no timeout)
curl at line 12 (1 allowed to fail, no timeout)
Writes these files:
/etc/cleanup.state at line 11
</code></pre>
<p>And <code>--policy</code> enforces rules before anything is spawned:</p>
<pre><code class="language-policy">forbid running "rm" with "-rf"
forbid writing to "/etc/*"
require timeout on "curl"
require every command to be checked
</code></pre>
<pre><code class="language-text">REFUSED: running "rm" with "-rf"
cleanup.frost:8 run "rm" with "-rf", scratch folder
2 rule violation(s); the script was not run.
</code></pre>
<p><code>rm -rf "$DIR"</code> in bash is a string until it executes, so there is nothing to
inspect first. In frost the program and its arguments are separate nodes, which
is what makes a script checkable as a contract instead of trusted as a guess.</p>
<h3 id="guide-built-in-checks">Built-in checks</h3>
<p>Beyond a policy file, every script gets a standing set of checks. On a script
that looks like routine cleanup:</p>
<pre><code class="language-text">Findings:
[DANGER ] line 12 Recursive forced delete
[DANGER ] line 15 Writes to a system location (/etc/cleanup.state)
[DANGER ] line 18 Permissive or recursive permission change
[caution] line 21 No timeout on curl
[caution] line 21 Failure ignored (curl)
[DANGER ] line 26 Downloaded code piped into a shell
Verdict: dangerous
</code></pre>
<p><a href="https://keithadler.github.io/frost/audit.html">The audit report</a>, or <code>open audit.html</code> locally, shows four scripts, a fake "dotfile backup"
that exfiltrates your keys and a cleanup script that quietly does four dangerous
things, both refused, alongside a health check and a log analyzer that pass.</p>
<h2 id="guide-install">Install</h2>
<p>Requires Python 3.10+. No dependencies.</p>
<pre><code class="language-bash">pip install frostlang
frost --version
</code></pre>
<p>Or with Homebrew:</p>
<pre><code class="language-bash">brew install keithadler/frost/frost
</code></pre>
<p>The keystore is the one optional extra, because it needs a real cipher:</p>
<pre><code class="language-bash">pip install "frostlang[keystore]"
</code></pre>
<p>From a checkout instead, which is what you want if you are changing frost:</p>
<pre><code class="language-bash">git clone https://github.com/keithadler/frost.git && cd frost
ln -s "$PWD/frost" /usr/local/bin/frost
frost examples/hello.frost
</code></pre>
<p>Coexists with zsh: you are adding an interpreter, not replacing your shell.
Make scripts executable with a shebang and run them directly:</p>
<pre><code class="language-bash">chmod +x report.frost
./report.frost access.log
</code></pre>
<h2 id="guide-try-it-in-30-seconds">Try it in 30 seconds</h2>
<p>No dependencies beyond Python 3.10. Paste this whole block:</p>
<pre><code class="language-bash">git clone https://github.com/keithadler/frost.git && cd frost
cat > hello.frost <<'END'
run "date" with "+%A"
put "Today is" && it
put the number of lines in file "/etc/hosts" into host lines
put "Your hosts file has" && host lines && "lines"
END
./frost hello.frost
</code></pre>
<pre><code class="language-text">Today is Monday
Your hosts file has 4 lines
</code></pre>
<p>Now ask what that script is allowed to do, without running it:</p>
<pre><code class="language-bash">./frost --explain hello.frost
</code></pre>
<pre><code class="language-text">This script runs date, and reads 1 file (system).
Runs these programs:
date at line 1 (no timeout)
Reads these files:
/etc/hosts at line 4
Verdict: clean
</code></pre>
<p>Then try to break it. This filename is a shell injection payload:</p>
<pre><code class="language-bash">cat > risky.frost <<'END'
put "notes.txt; rm -rf *" into filename
run "touch" with filename
run "ls" with "-1"
put it
END
./frost risky.frost
</code></pre>
<pre><code class="language-text">hello.frost
keep_me.txt
notes.txt; rm -rf *
risky.frost