stacktrace-cli 0.7.1__py3-none-any.whl → 0.9.0__py3-none-any.whl
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.
- stacktrace_cli/__init__.py +1 -1
- stacktrace_cli/analysis.py +13 -1
- stacktrace_cli/cli.py +63 -17
- stacktrace_cli/correlate/observed.py +47 -21
- stacktrace_cli/cost/__init__.py +6 -0
- stacktrace_cli/cost/baseline.py +493 -0
- stacktrace_cli/cost/clock.py +40 -0
- stacktrace_cli/cost/components.py +143 -0
- stacktrace_cli/cost/evidence.py +69 -0
- stacktrace_cli/cost/ledger.py +58 -0
- stacktrace_cli/cost/measures.py +351 -0
- stacktrace_cli/cost/models.py +38 -0
- stacktrace_cli/cost/rules.py +1138 -0
- stacktrace_cli/cost/runs.py +126 -0
- stacktrace_cli/cost/tokens.py +69 -0
- stacktrace_cli/cost/totals.py +62 -0
- stacktrace_cli/daemon/cli.py +58 -0
- stacktrace_cli/daemon/config.py +42 -0
- stacktrace_cli/daemon/costs.py +882 -0
- stacktrace_cli/daemon/db.py +65 -0
- stacktrace_cli/daemon/fleet.py +8 -3
- stacktrace_cli/daemon/jobs.py +96 -0
- stacktrace_cli/daemon/observer.py +17 -2
- stacktrace_cli/daemon/presentation.py +33 -0
- stacktrace_cli/daemon/runtime.py +122 -11
- stacktrace_cli/daemon/scan.py +31 -0
- stacktrace_cli/daemon/store.py +255 -2
- stacktrace_cli/daemon/webhook.py +3 -13
- stacktrace_cli/detector/analyzer.py +1 -3
- stacktrace_cli/detector/deterministic.py +440 -82
- stacktrace_cli/detector/finding.py +1 -16
- stacktrace_cli/detector/prompts/jev-v2/stacktrace-infra-unsafe-action.json +10 -0
- stacktrace_cli/detector/reasoning.py +133 -16
- stacktrace_cli/detector/render.py +14 -12
- stacktrace_cli/detector/rules.py +58 -4
- stacktrace_cli/detector/run.py +7 -5
- stacktrace_cli/detector/windows.py +150 -0
- stacktrace_cli/monitor/costs.py +375 -0
- stacktrace_cli/monitor/offline.py +266 -0
- stacktrace_cli/monitor/render.py +81 -23
- stacktrace_cli/monitor/server.py +5 -0
- stacktrace_cli/monitor/site/app.js +64 -1
- stacktrace_cli/monitor/site/cost.js +436 -0
- stacktrace_cli/monitor/site/index.html +8 -0
- stacktrace_cli/monitor/site/inventory.js +1 -1
- stacktrace_cli/monitor/site/styles.css +27 -303
- stacktrace_cli/monitor/state.py +5 -0
- stacktrace_cli/monitor/watch.py +16 -0
- stacktrace_cli/remote/policy_service.py +16 -1
- stacktrace_cli/remote/redact.py +0 -113
- stacktrace_cli/sessions/lineage.py +24 -0
- stacktrace_cli/sessions/protocols.py +81 -8
- stacktrace_cli/telemetry/__init__.py +4 -3
- stacktrace_cli/telemetry/cli.py +13 -2
- stacktrace_cli/telemetry/events.py +256 -16
- stacktrace_cli/telemetry/posthog.py +11 -7
- stacktrace_cli/telemetry/state.py +32 -3
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/METADATA +28 -13
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/RECORD +61 -43
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.7.1.dist-info → stacktrace_cli-0.9.0.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
stacktrace_cli/analysis.py
CHANGED
|
@@ -62,6 +62,11 @@ class Analysis:
|
|
|
62
62
|
#: is not yet countable and that only calls needing no composition are
|
|
63
63
|
#: listed. `analyse` is always placed.
|
|
64
64
|
placed: bool = True
|
|
65
|
+
#: `newest_hour` on `analyse_progressively`'s first preview, which reads
|
|
66
|
+
#: only the newest hour of the range; None for the whole range. A page
|
|
67
|
+
#: summing totals needs to tell the two apart, and `placed` cannot: both
|
|
68
|
+
#: previews are unplaced.
|
|
69
|
+
slice: str | None = None
|
|
65
70
|
|
|
66
71
|
|
|
67
72
|
#: A count and an optional unit, anchored. Anchored rather than searched
|
|
@@ -451,6 +456,7 @@ def analyse_progressively(
|
|
|
451
456
|
preview: timedelta | None = PREVIEW_WINDOW,
|
|
452
457
|
cache: VerdictCache | None = None,
|
|
453
458
|
attach_advisories: Any = None,
|
|
459
|
+
build_all: Any = None,
|
|
454
460
|
analyzer: str = NO_ANALYZER,
|
|
455
461
|
jev_key: str | None = None,
|
|
456
462
|
history: DriftHistory | None = None,
|
|
@@ -463,6 +469,8 @@ def analyse_progressively(
|
|
|
463
469
|
`history` is accepted and unused: this path never requests reasoning, so
|
|
464
470
|
it never adds a point. It is here so the watcher can forward one options
|
|
465
471
|
dict to both entry points (the test in `tests/monitor/test_watch.py`).
|
|
472
|
+
`build_all` is `analyse`'s own seam, forwarded for the same reason: the
|
|
473
|
+
offline monitor (`monitor/offline.py`) passes one that builds nothing.
|
|
466
474
|
|
|
467
475
|
`analyse` answers once, which is right for a command that prints and exits
|
|
468
476
|
and wrong for a page meant to look alive. Measured over 339 real sessions
|
|
@@ -510,7 +518,10 @@ def analyse_progressively(
|
|
|
510
518
|
cutoff = window_end - preview
|
|
511
519
|
# A window already narrower than the preview is read once, not twice.
|
|
512
520
|
if cutoff > window_start:
|
|
513
|
-
yield
|
|
521
|
+
yield replace(
|
|
522
|
+
_unplaced(read(cutoff), window_start=window_start, window_end=window_end),
|
|
523
|
+
slice="newest_hour",
|
|
524
|
+
)
|
|
514
525
|
|
|
515
526
|
sessions = read(window_start)
|
|
516
527
|
if preview is not None:
|
|
@@ -521,6 +532,7 @@ def analyse_progressively(
|
|
|
521
532
|
bom_paths=bom_paths,
|
|
522
533
|
project_map=project_map,
|
|
523
534
|
attach_advisories=attach_advisories,
|
|
535
|
+
build_all=build_all,
|
|
524
536
|
).view
|
|
525
537
|
|
|
526
538
|
def _stage(run: DetectorRun, sessions: tuple[CorrelatedSession, ...]) -> Analysis:
|
stacktrace_cli/cli.py
CHANGED
|
@@ -38,12 +38,19 @@ from .options import analyzer_option, resolve_analyzer
|
|
|
38
38
|
from .remote.cli import main as remote_cmd
|
|
39
39
|
from .sessions.access import collect_sessions
|
|
40
40
|
from .sessions.render import render_json, render_text
|
|
41
|
-
from .telemetry import emit_error
|
|
41
|
+
from .telemetry import command_of, emit, emit_error
|
|
42
42
|
from .telemetry.cli import telemetry as telemetry_cmd
|
|
43
|
+
from .telemetry.events import remember_internal
|
|
43
44
|
from .webhook.cli import webhook as webhook_cmd
|
|
44
45
|
|
|
45
46
|
PASSTHROUGH: Final = ("scan", "bom", "policy")
|
|
46
47
|
|
|
48
|
+
#: Commands whose runs are never counted as `command_run` (ADR-0090).
|
|
49
|
+
#: `telemetry` is where the answer is given and read back: reading the
|
|
50
|
+
#: preference or turning it off must not send an event or create an identity,
|
|
51
|
+
#: and `telemetry on` already says what it did with `installed`.
|
|
52
|
+
UNCOUNTED: Final = frozenset({"telemetry"})
|
|
53
|
+
|
|
47
54
|
HOMEPAGE = "https://stacktrace.ai"
|
|
48
55
|
|
|
49
56
|
#: The unit is required here and optional in `analysis.parse_since`. That is
|
|
@@ -129,13 +136,24 @@ class SectionedGroup(click.Group):
|
|
|
129
136
|
"""
|
|
130
137
|
|
|
131
138
|
def invoke(self, ctx: click.Context) -> object:
|
|
132
|
-
"""Run the command; count an exception on the way out
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
pass-through
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
"""Run the command; count a completed run, or an exception, on the way out.
|
|
140
|
+
|
|
141
|
+
A command that completes -- returns, or ends with `sys.exit(0)` the way
|
|
142
|
+
an OpenACA pass-through does -- hands off exactly one `command_run`
|
|
143
|
+
naming it from `telemetry.events.COMMANDS`, and nothing else
|
|
144
|
+
(ADR-0090). That hand-off is `emit`: no network I/O on this path, and
|
|
145
|
+
a failure of any kind is swallowed there. `UNCOUNTED` commands hand
|
|
146
|
+
off nothing.
|
|
147
|
+
|
|
148
|
+
Click's own control flow travels as exceptions too -- `Exit` from help
|
|
149
|
+
or a pass-through's exit code, `Abort` on Ctrl-C, `UsageError` on a
|
|
150
|
+
typo -- and a bare one is neither a completed run nor an error in this
|
|
151
|
+
program, so it sends nothing and creates no identity. That includes
|
|
152
|
+
`Exit(0)`: it is how `--help` ends, so a command that means "done"
|
|
153
|
+
returns rather than calling `ctx.exit()`. `--version` and the group's
|
|
154
|
+
own help end before this method runs. Everything else is an error,
|
|
155
|
+
and only an allowlisted class name or `other` leaves (ADR-0039); a
|
|
156
|
+
failed command is counted as its `error`, not as a run.
|
|
139
157
|
|
|
140
158
|
A `ClickException` or an `Exit` a command site raised to wrap a real
|
|
141
159
|
failure (`raise click.ClickException(...) from exc`, or
|
|
@@ -158,21 +176,35 @@ class SectionedGroup(click.Group):
|
|
|
158
176
|
invalid arguments from the `error` metric. This package's own
|
|
159
177
|
operational wrappers always raise a bare `click.ClickException`, never
|
|
160
178
|
`UsageError`, to carry a real cause.
|
|
179
|
+
|
|
180
|
+
`UNCOUNTED` is checked on every path that would otherwise call
|
|
181
|
+
`emit_error`, not only `_count_run`'s: `telemetry off` wraps a failed
|
|
182
|
+
preference write in a caused `ClickException` the same way any other
|
|
183
|
+
operational wrapper does, and ADR-0090 requires that using `telemetry`
|
|
184
|
+
itself never creates an identity or sends an event, whether it
|
|
185
|
+
completes or fails (Codex review, PR #153).
|
|
161
186
|
"""
|
|
162
187
|
try:
|
|
163
|
-
|
|
188
|
+
result = super().invoke(ctx)
|
|
189
|
+
except SystemExit as exit_:
|
|
190
|
+
if exit_.code in (None, 0):
|
|
191
|
+
_count_run(ctx)
|
|
192
|
+
raise
|
|
164
193
|
except click.exceptions.Abort:
|
|
165
194
|
raise
|
|
166
195
|
except click.UsageError:
|
|
167
196
|
raise
|
|
168
197
|
except (click.exceptions.Exit, click.ClickException) as error:
|
|
169
198
|
cause = error.__cause__
|
|
170
|
-
if cause is not None:
|
|
171
|
-
emit_error(cause)
|
|
199
|
+
if cause is not None and ctx.invoked_subcommand not in UNCOUNTED:
|
|
200
|
+
emit_error(cause, "cli")
|
|
172
201
|
raise
|
|
173
202
|
except Exception as error:
|
|
174
|
-
|
|
203
|
+
if ctx.invoked_subcommand not in UNCOUNTED:
|
|
204
|
+
emit_error(error, "cli")
|
|
175
205
|
raise
|
|
206
|
+
_count_run(ctx)
|
|
207
|
+
return result
|
|
176
208
|
|
|
177
209
|
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
178
210
|
commands: list[tuple[str, click.Command]] = []
|
|
@@ -201,6 +233,12 @@ class SectionedGroup(click.Group):
|
|
|
201
233
|
formatter.write_dl(rows)
|
|
202
234
|
|
|
203
235
|
|
|
236
|
+
def _count_run(ctx: click.Context) -> None:
|
|
237
|
+
name = ctx.invoked_subcommand
|
|
238
|
+
if name not in UNCOUNTED:
|
|
239
|
+
emit("command_run", command=command_of(name))
|
|
240
|
+
|
|
241
|
+
|
|
204
242
|
@click.group(cls=SectionedGroup)
|
|
205
243
|
@click.option(
|
|
206
244
|
"--version",
|
|
@@ -212,6 +250,9 @@ class SectionedGroup(click.Group):
|
|
|
212
250
|
)
|
|
213
251
|
def main() -> None:
|
|
214
252
|
"""Stacktrace: agent composition analysis, detection and upload."""
|
|
253
|
+
# Before any command, so `STACKTRACE_INTERNAL` set in a shell reaches the
|
|
254
|
+
# daemon that command may start, which never reads a shell (ADR-0091).
|
|
255
|
+
remember_internal()
|
|
215
256
|
|
|
216
257
|
|
|
217
258
|
@click.command()
|
|
@@ -365,11 +406,14 @@ def detect(
|
|
|
365
406
|
) -> None:
|
|
366
407
|
"""Find security and reliability findings in what agents did.
|
|
367
408
|
|
|
368
|
-
|
|
369
|
-
outbound call
|
|
370
|
-
|
|
409
|
+
Four kinds of finding, under two families: a credential reaching an
|
|
410
|
+
outbound call, success reported against evidence of failure, and an
|
|
411
|
+
infrastructure change beyond what the user asked for are security; why
|
|
412
|
+
the agent stopped, and who can unblock it, is reliability.
|
|
371
413
|
The two families carry their own severity ladders, because a blocked agent
|
|
372
|
-
and a leaked credential cannot share one (ADR-0010).
|
|
414
|
+
and a leaked credential cannot share one (ADR-0010). Cost findings are the
|
|
415
|
+
daemon's, which judges a whole day and a 30-day baseline (ADR-0097);
|
|
416
|
+
`stacktrace findings` shows them.
|
|
373
417
|
|
|
374
418
|
Both stages read the record on this machine. Neither needs a model, a
|
|
375
419
|
credential or an account, and on a default run nothing derived from a
|
|
@@ -378,7 +422,9 @@ def detect(
|
|
|
378
422
|
"On a default run" is the whole of the qualification, and it is here rather
|
|
379
423
|
than left to the flags: `--reasoning --analyzer jev` adds a third stage
|
|
380
424
|
that sends per-call windows -- a completion claim, one verification command
|
|
381
|
-
and its output
|
|
425
|
+
and its output; and, for a mutating infrastructure command, that command
|
|
426
|
+
with the user's own requests in the session -- to TypeSafe's Jev
|
|
427
|
+
(ADR-0033). Both flags must be typed,
|
|
382
428
|
neither has a default, and this output names them when a session is left
|
|
383
429
|
unjudged, so it is a run a reader can ask for and therefore one this
|
|
384
430
|
paragraph has to account for.
|
|
@@ -1334,18 +1334,6 @@ def observed_across(*calls: ToolCallLike | None) -> tuple[str, ...]:
|
|
|
1334
1334
|
return tuple(sorted(names))
|
|
1335
1335
|
|
|
1336
1336
|
|
|
1337
|
-
def evidence_for(call: ToolCallLike, capability: str) -> dict[str, str] | None:
|
|
1338
|
-
"""What established this capability on this call, for a rule that cites it.
|
|
1339
|
-
|
|
1340
|
-
A grade alone tells a reader how sure to be; this tells them why, which is
|
|
1341
|
-
what makes the grade checkable rather than an assertion.
|
|
1342
|
-
"""
|
|
1343
|
-
for observation in observe(call):
|
|
1344
|
-
if observation.capability == capability:
|
|
1345
|
-
return dict(observation.evidence)
|
|
1346
|
-
return None
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
1337
|
def command_heads(command: object) -> tuple[str, ...]:
|
|
1350
1338
|
"""Every program a shell command runs, without its arguments.
|
|
1351
1339
|
|
|
@@ -1361,8 +1349,10 @@ def command_heads(command: object) -> tuple[str, ...]:
|
|
|
1361
1349
|
#: The control operators a command line is split on, longest first so `&&` is
|
|
1362
1350
|
#: never read as one `&`. `&` alone is deliberately absent, as it was from the
|
|
1363
1351
|
#: expression this replaces: backgrounding is under-split rather than
|
|
1364
|
-
#: mis-split, which costs a segment and never invents one.
|
|
1365
|
-
|
|
1352
|
+
#: mis-split, which costs a segment and never invents one. `|&` is a pipe that
|
|
1353
|
+
#: carries stderr too, and is reported as `|`: read as `|` and then `&`, its
|
|
1354
|
+
#: receiver began with `&` and was dropped as punctuation.
|
|
1355
|
+
_SEPARATORS = ("&&", "||", "|&", ";", "|", "\n")
|
|
1366
1356
|
|
|
1367
1357
|
#: Characters after which the shell is at the start of a word, so a `#` there
|
|
1368
1358
|
#: opens a comment. Blanks and the newline are the ordinary case; the operator
|
|
@@ -1386,7 +1376,8 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
|
|
|
1386
1376
|
|
|
1387
1377
|
Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
|
|
1388
1378
|
operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
|
|
1389
|
-
``|`` or ``\\n`` that separated it from the one before it
|
|
1379
|
+
``|`` or ``\\n`` that separated it from the one before it (``|&`` is a
|
|
1380
|
+
pipe, and carried as ``|``).
|
|
1390
1381
|
|
|
1391
1382
|
A control operator is an operator **only unquoted and unescaped** — that is
|
|
1392
1383
|
the shell's own grammar, and it is verified against `bash` itself in
|
|
@@ -1474,6 +1465,15 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
|
|
|
1474
1465
|
newline = command.find("\n", index)
|
|
1475
1466
|
index = len(command) if newline == -1 else newline
|
|
1476
1467
|
continue
|
|
1468
|
+
if command.startswith(">|", index):
|
|
1469
|
+
# The clobbering redirection is one operator: its `|` is not a pipe.
|
|
1470
|
+
# Read as one, `echo safe >| sqlite3 db 'DROP …'` -- which writes a
|
|
1471
|
+
# file named `sqlite3` -- fabricated a piped `sqlite3` (P2, PR #130
|
|
1472
|
+
# review).
|
|
1473
|
+
current.append(">|")
|
|
1474
|
+
index += 2
|
|
1475
|
+
at_word_start = True
|
|
1476
|
+
continue
|
|
1477
1477
|
separator = next((s for s in _SEPARATORS if command.startswith(s, index)), None)
|
|
1478
1478
|
if separator is None:
|
|
1479
1479
|
current.append(char)
|
|
@@ -1481,15 +1481,16 @@ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
|
|
|
1481
1481
|
at_word_start = char in _WORD_START_BEFORE_COMMENT
|
|
1482
1482
|
continue
|
|
1483
1483
|
segment_text = "".join(current)
|
|
1484
|
+
operator = "|" if separator == "|&" else separator
|
|
1484
1485
|
if segment_text.strip():
|
|
1485
1486
|
parts.append((segment_text, preceding_op))
|
|
1486
|
-
preceding_op =
|
|
1487
|
+
preceding_op = operator
|
|
1487
1488
|
else:
|
|
1488
1489
|
# Two separators with nothing between them (e.g. "&&\n"):
|
|
1489
1490
|
# keep the conditional operator over a plain terminator so
|
|
1490
1491
|
# the next real segment inherits the right dependency.
|
|
1491
1492
|
if separator in ("&&", "||") or preceding_op is None:
|
|
1492
|
-
preceding_op =
|
|
1493
|
+
preceding_op = operator
|
|
1493
1494
|
current = []
|
|
1494
1495
|
index += len(separator)
|
|
1495
1496
|
at_word_start = True
|
|
@@ -1871,22 +1872,47 @@ def _strip_heredocs(command: str) -> str:
|
|
|
1871
1872
|
"""Drop heredoc bodies, keeping the lines that introduce them.
|
|
1872
1873
|
|
|
1873
1874
|
A heredoc the shell never sees closed consumes the rest of the input, and
|
|
1874
|
-
so does this:
|
|
1875
|
+
so does this: `_heredoc_lines` runs off the end and everything after the
|
|
1875
1876
|
introducer is dropped. That is bash's own behaviour, not a fallback -- it
|
|
1876
1877
|
reads to end-of-input looking for a terminator that is not there.
|
|
1877
1878
|
"""
|
|
1878
|
-
|
|
1879
|
+
return "\n".join(line for line, _bodies in _heredoc_lines(command))
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def heredoc_bodies(command: str) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
|
1883
|
+
"""Each line of `command` that opens heredocs, with their bodies in the
|
|
1884
|
+
order bash reads them.
|
|
1885
|
+
|
|
1886
|
+
What `_strip_heredocs` drops, kept: a body is the text the shell feeds a
|
|
1887
|
+
program's stdin, which is what a caller asking what a program was *given*
|
|
1888
|
+
-- SQL fed to a database client -- needs and `command_segments` never
|
|
1889
|
+
returns. Arithmetic is masked first, as `_readable_commands` masks it, so a
|
|
1890
|
+
`<<` inside `$(( … ))` opens nothing here either.
|
|
1891
|
+
"""
|
|
1892
|
+
return tuple(
|
|
1893
|
+
(line, tuple(bodies))
|
|
1894
|
+
for line, bodies in _heredoc_lines(_mask_arithmetic(command))
|
|
1895
|
+
if bodies
|
|
1896
|
+
)
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def _heredoc_lines(command: str) -> list[tuple[str, list[str]]]:
|
|
1900
|
+
"""Each line that is not a heredoc body, with the bodies it opens."""
|
|
1901
|
+
kept: list[tuple[str, list[str]]] = []
|
|
1879
1902
|
lines = command.splitlines()
|
|
1880
1903
|
index = 0
|
|
1881
1904
|
while index < len(lines):
|
|
1882
1905
|
line = lines[index]
|
|
1883
|
-
|
|
1906
|
+
bodies: list[str] = []
|
|
1907
|
+
kept.append((line, bodies))
|
|
1884
1908
|
index += 1
|
|
1885
1909
|
# In order, because that is the order bash reads the bodies in: a line
|
|
1886
1910
|
# opening two heredocs has the first body directly after it and the
|
|
1887
1911
|
# second after that one's terminator.
|
|
1888
1912
|
for dash, terminator in _heredoc_introducers(line):
|
|
1913
|
+
start = index
|
|
1889
1914
|
while index < len(lines) and not _closes_heredoc(lines[index], terminator, dash):
|
|
1890
1915
|
index += 1
|
|
1916
|
+
bodies.append("\n".join(lines[start:index]))
|
|
1891
1917
|
index += 1
|
|
1892
|
-
return
|
|
1918
|
+
return kept
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""What agent work uses, in tokens, and how far above the usual (ADR-0099).
|
|
2
|
+
|
|
3
|
+
Pure functions over the session shape (`sessions/protocols.py`). Nothing here
|
|
4
|
+
reads a file, a clock or the store; hosts pass those in. Nothing here prices:
|
|
5
|
+
every figure is tokens, or a ratio to the usual.
|
|
6
|
+
"""
|