approval-md 0.3.0 → 0.4.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/README.md +83 -914
- package/SPEC.md +98 -13
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +19 -0
- package/dist/src/channels/contract.js +105 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +51 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +50 -21
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +50 -6
|
@@ -58,6 +58,14 @@ import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTar
|
|
|
58
58
|
* is never written to any envelope and no policy rule should name it.
|
|
59
59
|
*/
|
|
60
60
|
export const GATE_SELF_CLASS = "gate.self";
|
|
61
|
+
/**
|
|
62
|
+
* The class a write under `$HERMES_HOME/scripts/` takes (APRV-445): scheduling
|
|
63
|
+
* code that Hermes's cron runs later with no hook in front of it. Returned by
|
|
64
|
+
* {@link protectedPathClass} beside the organs, ranked with `policy.edit`, and
|
|
65
|
+
* deliberately not a {@link ProtectedPathClass}: it is a side effect an
|
|
66
|
+
* operator prices, not a surface of the gate itself.
|
|
67
|
+
*/
|
|
68
|
+
export const HERMES_SCHEDULED_SCRIPT_CLASS = "cron.manage";
|
|
61
69
|
/**
|
|
62
70
|
* The `policy.edit` sub-class namespace a `protected_paths` entry may route to
|
|
63
71
|
* (APRV-266).
|
|
@@ -286,6 +294,79 @@ export function protectedPathClass(candidate, extra = []) {
|
|
|
286
294
|
return "policy.core";
|
|
287
295
|
}
|
|
288
296
|
}
|
|
297
|
+
// Hermes Agent's equivalent (APRV-398), and the one whose organ lives in
|
|
298
|
+
// the USER HOME by design rather than in the repository. Hermes reads its
|
|
299
|
+
// configuration from `$HERMES_HOME` (default `~/.hermes`) and documents no
|
|
300
|
+
// project-local directory, so the paths below are the ones a real install
|
|
301
|
+
// carries: `config.yaml`, which is where the `hooks:` entries are written;
|
|
302
|
+
// `agent-hooks/`, which holds the scripts those entries run; and
|
|
303
|
+
// `shell-hooks-allowlist.json`, which records the operator's one-time TTY
|
|
304
|
+
// consent to a hook command and would let an agent pre-consent itself to a
|
|
305
|
+
// command of its own. Same property as `.cursor/hooks.json`: an agent that
|
|
306
|
+
// could write any of these could write itself out of the gate.
|
|
307
|
+
//
|
|
308
|
+
// NO NEW PATH GRAMMAR IS NEEDED FOR THE HOME CASE, and that is worth
|
|
309
|
+
// stating because it is the one thing about this entry that differs from
|
|
310
|
+
// the four above it. This loop walks EVERY segment of the candidate, so
|
|
311
|
+
// `.hermes/config.yaml`, `/Users/x/.hermes/config.yaml` and
|
|
312
|
+
// `~/.hermes/config.yaml` all match at whichever position `.hermes` sits
|
|
313
|
+
// in; the existing matchers were already position-agnostic and nothing was
|
|
314
|
+
// extended for this. What a home path does change is the CLASS a mere READ
|
|
315
|
+
// of one takes: `cat ~/.hermes/config.yaml` is a read of a path outside the
|
|
316
|
+
// gate root, so the read jail answers it as `read.file.out_of_scope`
|
|
317
|
+
// (human-only in this repository's policy) before this tier is consulted,
|
|
318
|
+
// while a WRITE to it is `policy.core` here. Both refuse under an agent;
|
|
319
|
+
// they refuse for different reasons, and `docs/hermes-hook.md` says so.
|
|
320
|
+
//
|
|
321
|
+
// The bare `.hermes` directory itself lands here, exactly as bare
|
|
322
|
+
// `.approval` and bare `.codex` do: `rm -rf ~/.hermes` removes the gate's
|
|
323
|
+
// own installation. Anything else under it is deliberately NOT listed —
|
|
324
|
+
// session state and transcripts are ordinary bookkeeping, and pricing those
|
|
325
|
+
// at a human's attention is the failure mode §11 asks to avoid, the same
|
|
326
|
+
// reasoning that keeps `.muse/worktrees/` out of the entry above.
|
|
327
|
+
//
|
|
328
|
+
// APRV-445 adds three rows from the Agent Village DATA-234 survey of Hermes
|
|
329
|
+
// v2026.9.24. `shell-hooks-allowlist.json.lock` is the allowlist's flock
|
|
330
|
+
// sidecar: an agent that makes it unwritable makes the gateway's consent
|
|
331
|
+
// write raise at start, so it is the allowlist's organ as much as the file
|
|
332
|
+
// is (matched by prefix, so the sidecar and the file are one row).
|
|
333
|
+
// `approval/` is where a hosted install keeps the hook's own configuration
|
|
334
|
+
// beside the harness. And `scripts/` is `cron.manage`, not an organ: Hermes
|
|
335
|
+
// runs a scheduled job's script from `$HERMES_HOME/scripts/` with no
|
|
336
|
+
// `pre_tool_call` at all, so WRITING one is scheduling code that will run
|
|
337
|
+
// ungated later, which is the same act as `cronjob_manage create` and is
|
|
338
|
+
// priced by the same policy line.
|
|
339
|
+
//
|
|
340
|
+
// `.env` (and `.env.*`) and `auth.json` are organs too, for WRITES (APRV-445
|
|
341
|
+
// refutation, B2). `isCredentialPath` makes a READ of them
|
|
342
|
+
// `account.credential`, but that tier is consulted for reads only, so before
|
|
343
|
+
// this a `write_file`, a `patch`, a `>>`, a `sed -i` or a `tee -a` onto
|
|
344
|
+
// `$HERMES_HOME/.env` classified as an ordinary `files.write.workspace` and
|
|
345
|
+
// ran unattended: and Hermes loads that file with `override=True` into every
|
|
346
|
+
// process it starts, which makes it the switch for the gate itself
|
|
347
|
+
// (`HERMES_ACCEPT_HOOKS`) as well as a secret store. Reads stay
|
|
348
|
+
// `account.credential`.
|
|
349
|
+
if (segment === ".hermes") {
|
|
350
|
+
// Recheck 3 (lows): a profile home, `.hermes/profiles/<p>/`, is a home of
|
|
351
|
+
// its own with the same organs, so its paths are judged one level down.
|
|
352
|
+
const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
|
|
353
|
+
? segments[index + 3]
|
|
354
|
+
: segments[index + 1];
|
|
355
|
+
if (next === undefined ||
|
|
356
|
+
next === "config.yaml" ||
|
|
357
|
+
next === "config.yml" ||
|
|
358
|
+
next === "agent-hooks" ||
|
|
359
|
+
next === "approval" ||
|
|
360
|
+
next === ".env" ||
|
|
361
|
+
next.startsWith(".env.") ||
|
|
362
|
+
next === "auth.json" ||
|
|
363
|
+
next.startsWith("shell-hooks-allowlist.json") ||
|
|
364
|
+
next.startsWith("hooks")) {
|
|
365
|
+
return "policy.core";
|
|
366
|
+
}
|
|
367
|
+
if (next === "scripts")
|
|
368
|
+
return HERMES_SCHEDULED_SCRIPT_CLASS;
|
|
369
|
+
}
|
|
289
370
|
// Codex installs its hook through these configuration and script paths.
|
|
290
371
|
if (segment === ".codex") {
|
|
291
372
|
const next = segments[index + 1];
|
|
@@ -429,12 +510,34 @@ const CREDENTIAL_CLASS = "account.credential";
|
|
|
429
510
|
* kin), which is the environment map holding the Telegram token, the vault
|
|
430
511
|
* passphrase and the sampling secret.
|
|
431
512
|
*/
|
|
432
|
-
function isCredentialPath(candidate) {
|
|
513
|
+
export function isCredentialPath(candidate) {
|
|
433
514
|
if (candidate.length === 0)
|
|
434
515
|
return false;
|
|
435
516
|
const segments = pathSegments(candidate);
|
|
436
517
|
for (let index = 0; index < segments.length; index += 1) {
|
|
437
|
-
|
|
518
|
+
const segment = segments[index];
|
|
519
|
+
// APRV-398. Hermes Agent's own home holds two files of credential material
|
|
520
|
+
// beside its configuration: `.env`, which is where its secrets go, and
|
|
521
|
+
// `auth.json`, which holds an OAuth grant. `$HERMES_HOME/config.yaml` and
|
|
522
|
+
// the hook paths beside it are `policy.core` (see `protectedPathClass`);
|
|
523
|
+
// these two are a different class for the reason `.approval/env` is, which
|
|
524
|
+
// is that what leaves the machine is the secret rather than the rule.
|
|
525
|
+
//
|
|
526
|
+
// Added by the task that taught this runtime about Hermes at all. A harness
|
|
527
|
+
// whose organ this codebase now recognises, whose credential files it did
|
|
528
|
+
// not, would be a Never-list item the classifier does not enforce — which is
|
|
529
|
+
// exactly the hole APRV-194 was filed for, one harness along.
|
|
530
|
+
if (segment === ".hermes") {
|
|
531
|
+
const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
|
|
532
|
+
? segments[index + 3]
|
|
533
|
+
: segments[index + 1];
|
|
534
|
+
if (next === ".env" || next?.startsWith(".env.") === true)
|
|
535
|
+
return true;
|
|
536
|
+
if (next === "auth.json")
|
|
537
|
+
return true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (segment !== ".approval")
|
|
438
541
|
continue;
|
|
439
542
|
const next = segments[index + 1];
|
|
440
543
|
if (next === undefined)
|
|
@@ -1181,6 +1284,11 @@ const HARNESS_BINS = {
|
|
|
1181
1284
|
grok: "grok",
|
|
1182
1285
|
claude: "claude",
|
|
1183
1286
|
"cursor-agent": "cursor",
|
|
1287
|
+
// APRV-398. `hermes` is the console script Hermes Agent installs, and the
|
|
1288
|
+
// harness kind and the binary share one spelling, so the class reads
|
|
1289
|
+
// `harness.launch.hermes` beside the `hermes` adapter and the `.hermes/`
|
|
1290
|
+
// protected paths with no name mapping in between.
|
|
1291
|
+
hermes: "hermes",
|
|
1184
1292
|
};
|
|
1185
1293
|
/**
|
|
1186
1294
|
* Package specs a package runner may name, EXACTLY, to the same harness names.
|
|
@@ -1198,6 +1306,13 @@ const HARNESS_PACKAGES = {
|
|
|
1198
1306
|
"cursor-agent": "cursor",
|
|
1199
1307
|
muse: "muse",
|
|
1200
1308
|
grok: "grok",
|
|
1309
|
+
// APRV-398. Hermes Agent is a Python distribution, so a package runner is not
|
|
1310
|
+
// how it is launched; the entries exist for the same reason the `muse` and
|
|
1311
|
+
// `grok` ones do, which is that a spec this table does not know keeps
|
|
1312
|
+
// whatever class the runner already had, and a spec it does know cannot be
|
|
1313
|
+
// spelled around the direct row.
|
|
1314
|
+
hermes: "hermes",
|
|
1315
|
+
"hermes-agent": "hermes",
|
|
1201
1316
|
};
|
|
1202
1317
|
/**
|
|
1203
1318
|
* Argv that starts nothing: a version or help probe.
|
|
@@ -1498,6 +1613,429 @@ function refineSed(ctx) {
|
|
|
1498
1613
|
? { class: "files.write.workspace", rule: "sed-in-place" }
|
|
1499
1614
|
: { class: "read.shell", rule: "sed-read" };
|
|
1500
1615
|
}
|
|
1616
|
+
// ===========================================================================
|
|
1617
|
+
// Packaging and archives (APRV-397)
|
|
1618
|
+
// ===========================================================================
|
|
1619
|
+
/**
|
|
1620
|
+
* The read-only packaging and archive tools, and the one rule they share.
|
|
1621
|
+
*
|
|
1622
|
+
* ## What was wrong
|
|
1623
|
+
*
|
|
1624
|
+
* A release verification is mostly reading: pack a tarball, list what is in it,
|
|
1625
|
+
* unpack it somewhere disposable, hash a file, decode a base64 blob, ask a
|
|
1626
|
+
* binary its version. Every one of those commands was `unclassified` until this
|
|
1627
|
+
* task, so every one of them was DENIED, and the verifier that met the wall on
|
|
1628
|
+
* 2026-09-20 did the work anyway by fetching the tarball with `curl` and
|
|
1629
|
+
* parsing it in a script: a chain of network and interpreter calls in place of
|
|
1630
|
+
* six readers. A gate that refuses the legible spelling and leaves the
|
|
1631
|
+
* illegible one open has made the session less inspectable rather than safer.
|
|
1632
|
+
*
|
|
1633
|
+
* ## The one rule
|
|
1634
|
+
*
|
|
1635
|
+
* Each of these commands either reads what it names or writes into a
|
|
1636
|
+
* destination it names, and the destination is in the text. So they are decided
|
|
1637
|
+
* by the arithmetic {@link refineRm} already uses, hoisted into
|
|
1638
|
+
* {@link scopedWrite}: a destination strictly under a scratch root the CALLER
|
|
1639
|
+
* resolved is the agent's own space, a relative destination is the workspace,
|
|
1640
|
+
* and an absolute path anywhere else, a `..` segment, or a value the text cannot
|
|
1641
|
+
* read is out of scope.
|
|
1642
|
+
*
|
|
1643
|
+
* ## Why the out-of-scope answer is a class that already exists
|
|
1644
|
+
*
|
|
1645
|
+
* {@link OUT_OF_SCOPE_WRITE_CLASS} is `files.delete.out_of_scope`, which is
|
|
1646
|
+
* what `rm` and `find -delete` already answer for a destructive file operation
|
|
1647
|
+
* the text places outside the workspace. Minting `files.write.out_of_scope`
|
|
1648
|
+
* instead would have been the one change here capable of loosening something: a
|
|
1649
|
+
* class no policy names resolves by `defaults.autonomy` (SPEC.md §7), so in
|
|
1650
|
+
* every deployment whose defaults are permissive a brand-new name arrives
|
|
1651
|
+
* autonomous, while the existing class is held at `manual` by the reference
|
|
1652
|
+
* policy and by this repository's. An extraction is also honestly described by
|
|
1653
|
+
* it: unpacking an archive over a directory overwrites whatever it finds there,
|
|
1654
|
+
* which is the destruction the class is about.
|
|
1655
|
+
*
|
|
1656
|
+
* The strictness this inherits is `rm`'s, and it is deliberate. An ABSOLUTE
|
|
1657
|
+
* destination is out of scope even when it happens to sit inside the checkout,
|
|
1658
|
+
* because this file holds no workspace root, and the read roots it does hold
|
|
1659
|
+
* are a read notion that must not become a write authorization.
|
|
1660
|
+
*/
|
|
1661
|
+
const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
|
|
1662
|
+
/** The one rule id every out-of-scope packaging destination reports. */
|
|
1663
|
+
const OUT_OF_SCOPE_WRITE_RULE = "packaging-write-out-of-scope";
|
|
1664
|
+
/**
|
|
1665
|
+
* A destination a flag names, as a list {@link scopedWrite} can read.
|
|
1666
|
+
*
|
|
1667
|
+
* Three answers, and the third is the one worth naming: `[]` when the flag is
|
|
1668
|
+
* absent (the command writes where it stands, which is the working directory),
|
|
1669
|
+
* `[value]` when the flag names something the text can read, and `[null]` when
|
|
1670
|
+
* the flag is PRESENT and its value is not in the words. The last is a refusal
|
|
1671
|
+
* rather than an absence, because a flag present with nothing readable after it
|
|
1672
|
+
* would otherwise be answered as "no destination named" and take the looser
|
|
1673
|
+
* branch.
|
|
1674
|
+
*/
|
|
1675
|
+
function namedDestination(args, names) {
|
|
1676
|
+
if (!hasFlag(args, names))
|
|
1677
|
+
return [];
|
|
1678
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1679
|
+
const arg = args[index];
|
|
1680
|
+
const equals = arg.indexOf("=");
|
|
1681
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
1682
|
+
if (!names.includes(name))
|
|
1683
|
+
continue;
|
|
1684
|
+
if (equals !== -1) {
|
|
1685
|
+
const value = arg.slice(equals + 1);
|
|
1686
|
+
return [value.length === 0 ? null : value];
|
|
1687
|
+
}
|
|
1688
|
+
const value = args[index + 1];
|
|
1689
|
+
return [value === undefined || isFlag(value) ? null : value];
|
|
1690
|
+
}
|
|
1691
|
+
return [null];
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* The class of a write into destinations the command names (APRV-397).
|
|
1695
|
+
*
|
|
1696
|
+
* The same fail-closed branches {@link refineRm} has, in the same order,
|
|
1697
|
+
* against the same roots: all-scratch loosens, and an unreadable destination, an
|
|
1698
|
+
* absolute one, or a `..` segment is out of scope with the destination BOUND so
|
|
1699
|
+
* an approver is told which path earned the class. An empty destination list is
|
|
1700
|
+
* the working directory, which this file cannot see and must not guess at, so it
|
|
1701
|
+
* takes the row's own workspace answer exactly as `rm` with no target does.
|
|
1702
|
+
*/
|
|
1703
|
+
function scopedWrite(ctx, destinations, rule) {
|
|
1704
|
+
const named = destinations.filter((entry) => entry !== null);
|
|
1705
|
+
if (named.length < destinations.length) {
|
|
1706
|
+
return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE };
|
|
1707
|
+
}
|
|
1708
|
+
if (allTargetsAreScratch(named, ctx.context.scratchRoots ?? [])) {
|
|
1709
|
+
return { class: "files.write.workspace", rule };
|
|
1710
|
+
}
|
|
1711
|
+
for (const destination of named) {
|
|
1712
|
+
if (destination.startsWith("/") ||
|
|
1713
|
+
pathSegments(destination).includes("..") ||
|
|
1714
|
+
isUnknownValue(destination)) {
|
|
1715
|
+
return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE, path: destination };
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return { class: "files.write.workspace", rule };
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* An argv that asks a binary about itself: a version or a usage string.
|
|
1722
|
+
*
|
|
1723
|
+
* The harness table has its own copy of this idea ({@link HARNESS_PROBE_FLAGS})
|
|
1724
|
+
* and keeps it, because a harness probe still EXECUTES a whole second agent's
|
|
1725
|
+
* binary and is named in {@link CODE_EXECUTING_RULES} for that reason. This one
|
|
1726
|
+
* is the package managers', where `npm --version` prints a string and starts
|
|
1727
|
+
* nothing at all. `-v` is here and absent there: it is npm's version flag and
|
|
1728
|
+
* nothing else's.
|
|
1729
|
+
*/
|
|
1730
|
+
const VERSION_PROBE_FLAGS = ["--version", "-v", "-V", "--help", "-h"];
|
|
1731
|
+
/** Is this argv nothing but probe flags? One word at minimum, so a bare binary is not a probe. */
|
|
1732
|
+
function isVersionProbe(args) {
|
|
1733
|
+
return args.length > 0 && args.every((arg) => VERSION_PROBE_FLAGS.includes(arg));
|
|
1734
|
+
}
|
|
1735
|
+
/** Positionals, with the values of value-taking flags left out of the count. */
|
|
1736
|
+
function positionalsBesideValues(args, valueFlags) {
|
|
1737
|
+
const out = [];
|
|
1738
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1739
|
+
const arg = args[index];
|
|
1740
|
+
if (isFlag(arg)) {
|
|
1741
|
+
if (valueFlags.includes(arg))
|
|
1742
|
+
index += 1;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
out.push(arg);
|
|
1746
|
+
}
|
|
1747
|
+
return out;
|
|
1748
|
+
}
|
|
1749
|
+
/** `npm pack`'s flags whose value is a path or a workspace name, never a package spec. */
|
|
1750
|
+
const NPM_PACK_VALUE_FLAGS = ["--pack-destination", "--workspace", "-w"];
|
|
1751
|
+
/**
|
|
1752
|
+
* Does this `npm pack` argument name something on disk rather than a registry
|
|
1753
|
+
* package?
|
|
1754
|
+
*
|
|
1755
|
+
* A path, in the spellings npm accepts for one. Everything else is a package
|
|
1756
|
+
* spec, which `npm pack` resolves by FETCHING it, and a value the text cannot
|
|
1757
|
+
* read is a spec too: the looser reading of an unreadable argument is the one
|
|
1758
|
+
* that lets a download through as a local pack.
|
|
1759
|
+
*/
|
|
1760
|
+
function isLocalPackSpec(spec) {
|
|
1761
|
+
if (isUnknownValue(spec))
|
|
1762
|
+
return false;
|
|
1763
|
+
if (spec.startsWith(".") || spec.startsWith("/"))
|
|
1764
|
+
return true;
|
|
1765
|
+
return spec.includes("/") && !spec.startsWith("@");
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* `npm pack` — a tarball into the working directory, or into
|
|
1769
|
+
* `--pack-destination`.
|
|
1770
|
+
*
|
|
1771
|
+
* Two answers beside the scoped write. A positional naming a REGISTRY package
|
|
1772
|
+
* is `network.call`, because that spelling downloads the package before it packs
|
|
1773
|
+
* it and the tarball it then writes is beside the point. And the write itself
|
|
1774
|
+
* runs the package's own `prepack` and `prepare` scripts, which is why the rule
|
|
1775
|
+
* id is in {@link CODE_EXECUTING_RULES}.
|
|
1776
|
+
*/
|
|
1777
|
+
function refineNpmPack(ctx) {
|
|
1778
|
+
const specs = positionalsBesideValues(ctx.args, NPM_PACK_VALUE_FLAGS).slice(1);
|
|
1779
|
+
if (specs.some((spec) => !isLocalPackSpec(spec))) {
|
|
1780
|
+
return { class: "network.call", rule: "npm-pack-remote" };
|
|
1781
|
+
}
|
|
1782
|
+
return scopedWrite(ctx, namedDestination(ctx.args, ["--pack-destination"]), "npm-pack");
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* `npm init` — `package.json` into the working directory.
|
|
1786
|
+
*
|
|
1787
|
+
* No flag moves it, so there is no destination to scope and the answer is the
|
|
1788
|
+
* workspace write the row declares. `npm init <pkg>` is a different act, since
|
|
1789
|
+
* it downloads an initializer and runs it, which is `npm exec` wearing another
|
|
1790
|
+
* name; it takes its own rule id and its seat in {@link CODE_EXECUTING_RULES}
|
|
1791
|
+
* beside `npm-script`.
|
|
1792
|
+
*/
|
|
1793
|
+
function refineNpmInit(ctx) {
|
|
1794
|
+
const initializer = positionalsBesideValues(ctx.args, ["--workspace", "-w"]).slice(1);
|
|
1795
|
+
return initializer.length === 0
|
|
1796
|
+
? { class: "files.write.workspace", rule: "npm-init" }
|
|
1797
|
+
: { class: "files.write.workspace", rule: "npm-init-create" };
|
|
1798
|
+
}
|
|
1799
|
+
/** `tar`'s long spellings of each mode, beside the one-letter ones. */
|
|
1800
|
+
const TAR_LIST_FLAGS = ["--list"];
|
|
1801
|
+
const TAR_EXTRACT_FLAGS = ["--extract", "--get"];
|
|
1802
|
+
const TAR_CREATE_FLAGS = [
|
|
1803
|
+
"--create",
|
|
1804
|
+
"--append",
|
|
1805
|
+
"--update",
|
|
1806
|
+
"--delete",
|
|
1807
|
+
"--concatenate",
|
|
1808
|
+
"--catenate",
|
|
1809
|
+
];
|
|
1810
|
+
/** The argv that ask `tar` about itself and open no archive at all. */
|
|
1811
|
+
const TAR_PROBE_FLAGS = ["--version", "--help", "--usage"];
|
|
1812
|
+
/**
|
|
1813
|
+
* The mode letters `tar` was given, from the short bundles and the old-style
|
|
1814
|
+
* first word.
|
|
1815
|
+
*
|
|
1816
|
+
* `tar xzf a.tgz` carries no dash at all, and it is the spelling most sessions
|
|
1817
|
+
* write, so a rule that read only `-x` would answer the common form with a
|
|
1818
|
+
* refusal. Only the FIRST word can be an old-style bundle, and a word is read as
|
|
1819
|
+
* a bundle only when it is letters and nothing else: `-fa.tgz` is a glued
|
|
1820
|
+
* filename, and a letter taken out of a filename is a mode the command never
|
|
1821
|
+
* named. Dropping such a word costs a refusal (the mode becomes unreadable) and
|
|
1822
|
+
* can never invent a read.
|
|
1823
|
+
*/
|
|
1824
|
+
function tarModeLetters(args) {
|
|
1825
|
+
let letters = "";
|
|
1826
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1827
|
+
const arg = args[index];
|
|
1828
|
+
if (/^-[A-Za-z]+$/u.test(arg))
|
|
1829
|
+
letters += arg.slice(1);
|
|
1830
|
+
else if (index === 0 && /^[A-Za-z]+$/u.test(arg))
|
|
1831
|
+
letters += arg;
|
|
1832
|
+
}
|
|
1833
|
+
return letters;
|
|
1834
|
+
}
|
|
1835
|
+
/** The tar letters that consume the next word, which is what makes a bundle ambiguous. */
|
|
1836
|
+
const TAR_VALUE_LETTERS = ["f", "C"];
|
|
1837
|
+
/** The option letters of a tar word, or `null` when it is not a bundle of them. */
|
|
1838
|
+
function tarBundle(arg, index) {
|
|
1839
|
+
if (/^-[A-Za-z]+$/u.test(arg))
|
|
1840
|
+
return arg.slice(1);
|
|
1841
|
+
if (index === 0 && /^[A-Za-z]+$/u.test(arg))
|
|
1842
|
+
return arg;
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The value a tar flag carries, in every spelling tar accepts for it.
|
|
1847
|
+
*
|
|
1848
|
+
* The glued bundle is the whole reason this exists rather than
|
|
1849
|
+
* {@link namedDestination}: `tar -czf out.tgz src` names its archive with an
|
|
1850
|
+
* `f` buried in `-czf`, which an exact-word flag match cannot see, and a rule
|
|
1851
|
+
* that missed it would answer the commonest spelling of a create with the
|
|
1852
|
+
* looser branch.
|
|
1853
|
+
*
|
|
1854
|
+
* One bundle carrying BOTH value-taking letters (`-xCf`) is `null`, which
|
|
1855
|
+
* {@link scopedWrite} refuses: which of the following words feeds which letter
|
|
1856
|
+
* depends on tar's own option order, so the destination is not in the text
|
|
1857
|
+
* (SPEC.md §11.1, ambiguity resolves to the stricter class).
|
|
1858
|
+
*/
|
|
1859
|
+
function tarFlagValue(args, letter, longNames) {
|
|
1860
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1861
|
+
const arg = args[index];
|
|
1862
|
+
const equals = arg.indexOf("=");
|
|
1863
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
1864
|
+
if (longNames.includes(name)) {
|
|
1865
|
+
if (equals === -1) {
|
|
1866
|
+
const next = args[index + 1];
|
|
1867
|
+
return [next === undefined || isFlag(next) ? null : next];
|
|
1868
|
+
}
|
|
1869
|
+
const value = arg.slice(equals + 1);
|
|
1870
|
+
return [value.length === 0 ? null : value];
|
|
1871
|
+
}
|
|
1872
|
+
const bundle = tarBundle(arg, index);
|
|
1873
|
+
if (bundle === null || !bundle.includes(letter))
|
|
1874
|
+
continue;
|
|
1875
|
+
if (TAR_VALUE_LETTERS.filter((candidate) => bundle.includes(candidate)).length > 1) {
|
|
1876
|
+
return [null];
|
|
1877
|
+
}
|
|
1878
|
+
const next = args[index + 1];
|
|
1879
|
+
return [next === undefined || isFlag(next) ? null : next];
|
|
1880
|
+
}
|
|
1881
|
+
return [];
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* `tar` — a listing reads, an extraction writes where `-C` points, a creation
|
|
1885
|
+
* writes the archive `-f` names.
|
|
1886
|
+
*
|
|
1887
|
+
* The write branch is checked FIRST and takes both destinations when one command
|
|
1888
|
+
* names both modes, because `tar` resolves a contradictory mode by its own
|
|
1889
|
+
* option order and the stricter reading is the one that assumes it writes.
|
|
1890
|
+
*
|
|
1891
|
+
* A mode the text does not carry is `opaque` rather than a guess, and that is
|
|
1892
|
+
* the honest code for it: a `tar` whose mode is not in its words is a command
|
|
1893
|
+
* whose effect is not in its words, which is what {@link OPAQUE_BINS} refuses
|
|
1894
|
+
* for the same reason.
|
|
1895
|
+
*/
|
|
1896
|
+
function refineTar(ctx) {
|
|
1897
|
+
const letters = tarModeLetters(ctx.args);
|
|
1898
|
+
const extracting = letters.includes("x") || hasFlag(ctx.args, TAR_EXTRACT_FLAGS);
|
|
1899
|
+
const creating = ["c", "r", "u", "A"].some((letter) => letters.includes(letter)) ||
|
|
1900
|
+
hasFlag(ctx.args, TAR_CREATE_FLAGS);
|
|
1901
|
+
if (extracting || creating) {
|
|
1902
|
+
const destinations = [
|
|
1903
|
+
...(extracting ? tarFlagValue(ctx.args, "C", ["--directory"]) : []),
|
|
1904
|
+
...(creating ? tarFlagValue(ctx.args, "f", ["--file"]) : []),
|
|
1905
|
+
];
|
|
1906
|
+
return scopedWrite(ctx, destinations, extracting ? "tar-extract" : "tar-create");
|
|
1907
|
+
}
|
|
1908
|
+
if (letters.includes("t") || hasFlag(ctx.args, TAR_LIST_FLAGS)) {
|
|
1909
|
+
return { class: "read.shell", rule: "tar-list" };
|
|
1910
|
+
}
|
|
1911
|
+
if (ctx.args.length > 0 && ctx.args.every((arg) => TAR_PROBE_FLAGS.includes(arg))) {
|
|
1912
|
+
return { class: "read.shell", rule: "tar-probe" };
|
|
1913
|
+
}
|
|
1914
|
+
return { opaque: "tar names no mode this classifier can read" };
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* `gunzip` — the stdout, test and list forms read; every other form REPLACES a
|
|
1918
|
+
* file.
|
|
1919
|
+
*
|
|
1920
|
+
* The default is the trap: `gunzip pkg.tgz` removes `pkg.tgz` and leaves
|
|
1921
|
+
* `pkg.tar` in its place, so the plain spelling is a write of the path it names.
|
|
1922
|
+
* Three flags leave the disk alone: `-c`/`--stdout` decompresses to standard
|
|
1923
|
+
* output, `-t`/`--test` checks the integrity of the archive, and `-l`/`--list`
|
|
1924
|
+
* prints the compressed and uncompressed sizes, the ratio and the member name.
|
|
1925
|
+
* A listing is the same kind of act as `tar -t` beside it and belongs in the
|
|
1926
|
+
* same class. `-k` (`--keep`) is deliberately not a read: it spares the input
|
|
1927
|
+
* and still creates the output.
|
|
1928
|
+
*/
|
|
1929
|
+
function refineGunzip(ctx) {
|
|
1930
|
+
const reading = hasShortFlag(ctx.args, ["c", "t", "l"]) ||
|
|
1931
|
+
hasFlag(ctx.args, ["--stdout", "--to-stdout", "--test", "--list"]);
|
|
1932
|
+
return reading
|
|
1933
|
+
? { class: "read.shell", rule: "gunzip-read" }
|
|
1934
|
+
: scopedWrite(ctx, ctx.positionals, "gunzip-write");
|
|
1935
|
+
}
|
|
1936
|
+
/** `base64` — a read of what it names, unless `-o` names a file to write. */
|
|
1937
|
+
function refineBase64(ctx) {
|
|
1938
|
+
const destination = namedDestination(ctx.args, ["-o", "--output"]);
|
|
1939
|
+
return destination.length === 0
|
|
1940
|
+
? { class: "read.shell", rule: "base64-read" }
|
|
1941
|
+
: scopedWrite(ctx, destination, "base64-write");
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* `openssl`'s digest subcommands, and only those.
|
|
1945
|
+
*
|
|
1946
|
+
* The row names the digests rather than the binary, so `openssl enc`, `genrsa`,
|
|
1947
|
+
* `req`, `rand` and `s_client` stay exactly as unclassified as they were. Each
|
|
1948
|
+
* of those writes key material, encrypts, or opens a socket, and none of them is
|
|
1949
|
+
* what a release verification runs; a row for the binary would have swept all of
|
|
1950
|
+
* them into a read.
|
|
1951
|
+
*/
|
|
1952
|
+
const OPENSSL_DIGEST_SUBS = [
|
|
1953
|
+
"dgst",
|
|
1954
|
+
"md5",
|
|
1955
|
+
"sha1",
|
|
1956
|
+
"sha256",
|
|
1957
|
+
"sha384",
|
|
1958
|
+
"sha512",
|
|
1959
|
+
];
|
|
1960
|
+
/** `openssl dgst …` — a read of its named files, unless `-out` names one to write. */
|
|
1961
|
+
function refineOpensslDigest(ctx) {
|
|
1962
|
+
const destination = namedDestination(ctx.args, ["-out"]);
|
|
1963
|
+
return destination.length === 0
|
|
1964
|
+
? { class: "read.shell", rule: "openssl-digest" }
|
|
1965
|
+
: scopedWrite(ctx, destination, "openssl-digest-out");
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* `git tag` — the listing forms read (APRV-397).
|
|
1969
|
+
*
|
|
1970
|
+
* APRV-305 gave the whole verb `release.publish`, which is right for the half
|
|
1971
|
+
* of it that creates, moves, signs and deletes tags: a tag is the name a
|
|
1972
|
+
* release was published under. It is wrong for the other half. `git tag -l` is
|
|
1973
|
+
* a listing of local refs, and on 2026-09-20 it put a nine-minute question on a
|
|
1974
|
+
* phone during a state check. Nothing about a listing reaches a registry, a
|
|
1975
|
+
* remote or a consumer, and the class it takes is `read.shell`, the class every
|
|
1976
|
+
* other reader of local repository metadata takes (`git log`, `git branch`,
|
|
1977
|
+
* `git status`).
|
|
1978
|
+
*
|
|
1979
|
+
* The split is an ALLOWLIST of the flags that list, and that direction is the
|
|
1980
|
+
* whole safety argument: a flag this rule has never heard of is a creation, so a
|
|
1981
|
+
* future `git tag` option cannot arrive as a read by default. A positional is a
|
|
1982
|
+
* TAG NAME, which creates one, unless `-l` is present and makes it a pattern.
|
|
1983
|
+
* The shape mirrors {@link refineGitBranch}, which splits read and write halves
|
|
1984
|
+
* of one verb for the same reason.
|
|
1985
|
+
*/
|
|
1986
|
+
const GIT_TAG_READ_FLAGS = [
|
|
1987
|
+
"-l",
|
|
1988
|
+
"--list",
|
|
1989
|
+
"-n",
|
|
1990
|
+
"-i",
|
|
1991
|
+
"--ignore-case",
|
|
1992
|
+
"--omit-empty",
|
|
1993
|
+
"--column",
|
|
1994
|
+
"--no-column",
|
|
1995
|
+
"--color",
|
|
1996
|
+
"--no-color",
|
|
1997
|
+
];
|
|
1998
|
+
/** Listing flags whose next word is a value rather than a tag name. */
|
|
1999
|
+
const GIT_TAG_READ_VALUE_FLAGS = [
|
|
2000
|
+
"--contains",
|
|
2001
|
+
"--no-contains",
|
|
2002
|
+
"--points-at",
|
|
2003
|
+
"--merged",
|
|
2004
|
+
"--no-merged",
|
|
2005
|
+
"--sort",
|
|
2006
|
+
"--format",
|
|
2007
|
+
];
|
|
2008
|
+
function refineGitTag(ctx) {
|
|
2009
|
+
const publish = { class: "release.publish", rule: "git-tag" };
|
|
2010
|
+
const args = ctx.args.slice(1);
|
|
2011
|
+
const names = [];
|
|
2012
|
+
let listing = false;
|
|
2013
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2014
|
+
const arg = args[index];
|
|
2015
|
+
if (!isFlag(arg)) {
|
|
2016
|
+
names.push(arg);
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
const equals = arg.indexOf("=");
|
|
2020
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
2021
|
+
if (name === "-l" || name === "--list") {
|
|
2022
|
+
listing = true;
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
if (GIT_TAG_READ_FLAGS.includes(name) || /^-n\d+$/u.test(arg))
|
|
2026
|
+
continue;
|
|
2027
|
+
if (GIT_TAG_READ_VALUE_FLAGS.includes(name)) {
|
|
2028
|
+
if (equals === -1)
|
|
2029
|
+
index += 1;
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
// Everything the allowlist does not name: a creation, a deletion, a
|
|
2033
|
+
// signature, a force-move, and also a flag that did not exist when this rule
|
|
2034
|
+
// was written.
|
|
2035
|
+
return publish;
|
|
2036
|
+
}
|
|
2037
|
+
return names.length > 0 && !listing ? publish : { class: "read.shell", rule: "git-tag-read" };
|
|
2038
|
+
}
|
|
1501
2039
|
/**
|
|
1502
2040
|
* The methods a fetch may name and still be a read: GET, and HEAD, which is a
|
|
1503
2041
|
* GET that discards the body. Everything else, including a method the
|
|
@@ -1865,6 +2403,19 @@ function refineApprovalVerb(positionals, args = []) {
|
|
|
1865
2403
|
return { class: "policy.core", rule: "approval-log-checkpoint" };
|
|
1866
2404
|
return null;
|
|
1867
2405
|
}
|
|
2406
|
+
// APRV-455. `channel relay` records gestures attributed to an EdgeOS account
|
|
2407
|
+
// on the word of whoever holds a secret the LAUNCHER chose. An agent that
|
|
2408
|
+
// could start one with a secret of its own, and post to it, would write
|
|
2409
|
+
// records that read as transport-authenticated evidence from a person's
|
|
2410
|
+
// session, which a Telegram listener cannot be made to do without the bot
|
|
2411
|
+
// token. So starting it is the operator's ceremony: `policy.core`, held
|
|
2412
|
+
// human-only by the reference policy, and the hook denies it with
|
|
2413
|
+
// `hook-class-human-only`. A deployment starts it from its own launcher, as
|
|
2414
|
+
// the store's user, outside any agent's hook. It mints no new class (SPEC.md
|
|
2415
|
+
// §11.1 invariant 9).
|
|
2416
|
+
if (verb === "channel" && sub === "relay") {
|
|
2417
|
+
return { class: "policy.core", rule: "approval-channel-relay" };
|
|
2418
|
+
}
|
|
1868
2419
|
if (verb === "gate") {
|
|
1869
2420
|
if (sub === "open")
|
|
1870
2421
|
return { class: "policy.core", rule: "approval-gate-open" };
|
|
@@ -1963,7 +2514,16 @@ export const COMMAND_RULES = [
|
|
|
1963
2514
|
{ id: "git-reset", bins: ["git"], subs: ["reset"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitReset },
|
|
1964
2515
|
{ id: "git-commit", bins: ["git"], subs: ["commit"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitCommit },
|
|
1965
2516
|
{ id: "git-branch", bins: ["git"], subs: ["branch"], class: "read.shell", emits: ["vcs.commit.branch"], refine: refineGitBranch },
|
|
1966
|
-
|
|
2517
|
+
// APRV-397 split the verb: the listing flags read, everything else publishes.
|
|
2518
|
+
// See {@link refineGitTag} for why the split is an allowlist.
|
|
2519
|
+
{
|
|
2520
|
+
id: "git-tag",
|
|
2521
|
+
bins: ["git"],
|
|
2522
|
+
subs: ["tag"],
|
|
2523
|
+
class: "release.publish",
|
|
2524
|
+
emits: ["read.shell"],
|
|
2525
|
+
refine: refineGitTag,
|
|
2526
|
+
},
|
|
1967
2527
|
{ id: "git-clone", bins: ["git"], subs: ["clone"], class: "network.call" },
|
|
1968
2528
|
{
|
|
1969
2529
|
id: "git-write",
|
|
@@ -2045,7 +2605,30 @@ export const COMMAND_RULES = [
|
|
|
2045
2605
|
refine: refineGh,
|
|
2046
2606
|
},
|
|
2047
2607
|
// -- package managers ----------------------------------------------------
|
|
2608
|
+
// APRV-397. The probe row matches ONLY an argv that is nothing but version or
|
|
2609
|
+
// help flags, which is what `probe` means and what keeps `npm doctor`,
|
|
2610
|
+
// `npm cache clean` and every other subcommand this table does not name at the
|
|
2611
|
+
// `unclassified` deny they have today. `npm version` is a different word and
|
|
2612
|
+
// keeps its `release.publish` row below.
|
|
2613
|
+
{ id: "npm-version", bins: ["npm", "pnpm", "yarn", "bun"], class: "read.shell", probe: true },
|
|
2048
2614
|
{ id: "npm-publish", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["publish", "version", "deprecate", "dist-tag", "unpublish"], class: "release.publish" },
|
|
2615
|
+
// APRV-397: the two package-manager verbs that write into a directory they
|
|
2616
|
+
// name rather than touching the registry or the lockfile.
|
|
2617
|
+
{
|
|
2618
|
+
id: "npm-pack",
|
|
2619
|
+
bins: ["npm", "pnpm", "yarn", "bun"],
|
|
2620
|
+
subs: ["pack"],
|
|
2621
|
+
class: "files.write.workspace",
|
|
2622
|
+
emits: [OUT_OF_SCOPE_WRITE_CLASS, "network.call"],
|
|
2623
|
+
refine: refineNpmPack,
|
|
2624
|
+
},
|
|
2625
|
+
{
|
|
2626
|
+
id: "npm-init",
|
|
2627
|
+
bins: ["npm", "pnpm", "yarn", "bun"],
|
|
2628
|
+
subs: ["init"],
|
|
2629
|
+
class: "files.write.workspace",
|
|
2630
|
+
refine: refineNpmInit,
|
|
2631
|
+
},
|
|
2049
2632
|
{ id: "npm-install", bins: ["npm", "bun"], subs: ["install", "i", "add"], class: "deps.add", emits: ["deps.install"], refine: refineNpmInstall },
|
|
2050
2633
|
{ id: "yarn-add", bins: ["yarn", "pnpm"], subs: ["add"], class: "deps.add" },
|
|
2051
2634
|
{ id: "yarn-install", bins: ["yarn", "pnpm"], subs: ["install"], class: "deps.install" },
|
|
@@ -2142,6 +2725,40 @@ export const COMMAND_RULES = [
|
|
|
2142
2725
|
emits: ["files.delete.out_of_scope", "files.write.workspace"],
|
|
2143
2726
|
refine: refineFind,
|
|
2144
2727
|
},
|
|
2728
|
+
// -- packaging and archives (APRV-397) -----------------------------------
|
|
2729
|
+
// Four binaries a release verification cannot do without, each of which either
|
|
2730
|
+
// reads what it names or writes into a destination it names. The section
|
|
2731
|
+
// comment above {@link OUT_OF_SCOPE_WRITE_CLASS} holds the reasoning; these
|
|
2732
|
+
// rows only say which binary reaches which refinement.
|
|
2733
|
+
{
|
|
2734
|
+
id: "tar",
|
|
2735
|
+
bins: ["tar"],
|
|
2736
|
+
class: "files.write.workspace",
|
|
2737
|
+
emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2738
|
+
refine: refineTar,
|
|
2739
|
+
},
|
|
2740
|
+
{
|
|
2741
|
+
id: "gunzip",
|
|
2742
|
+
bins: ["gunzip"],
|
|
2743
|
+
class: "files.write.workspace",
|
|
2744
|
+
emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2745
|
+
refine: refineGunzip,
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
id: "base64",
|
|
2749
|
+
bins: ["base64"],
|
|
2750
|
+
class: "read.shell",
|
|
2751
|
+
emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2752
|
+
refine: refineBase64,
|
|
2753
|
+
},
|
|
2754
|
+
{
|
|
2755
|
+
id: "openssl-digest",
|
|
2756
|
+
bins: ["openssl"],
|
|
2757
|
+
subs: OPENSSL_DIGEST_SUBS,
|
|
2758
|
+
class: "read.shell",
|
|
2759
|
+
emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2760
|
+
refine: refineOpensslDigest,
|
|
2761
|
+
},
|
|
2145
2762
|
// -- network -------------------------------------------------------------
|
|
2146
2763
|
// The HTTP clients split on their flags; the transports do not. What `ssh`,
|
|
2147
2764
|
// `rsync` or `nc` will do at the far end is not written in the argv, so there
|
|
@@ -2458,6 +3075,70 @@ function protectedRank(surface) {
|
|
|
2458
3075
|
const index = PROTECTED_PRECEDENCE.indexOf(surface);
|
|
2459
3076
|
return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
|
|
2460
3077
|
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Is this word PROSE that merely spells a protected path, rather than a path?
|
|
3080
|
+
* (APRV-409.)
|
|
3081
|
+
*
|
|
3082
|
+
* The incident: a `backlog task create` whose acceptance-criterion argument
|
|
3083
|
+
* began with the literal text of a workflow path and continued into a sentence
|
|
3084
|
+
* classified `policy.edit.ci`, sat the full hook wait on the gate and was
|
|
3085
|
+
* denied on timeout. The command writes one task file and touches no workflow.
|
|
3086
|
+
* {@link pathSegments} splits on slashes only, so a word whose leading segments
|
|
3087
|
+
* are a protected DIRECTORY run prefix-matches whatever follows those slashes,
|
|
3088
|
+
* sentence included. An exact-file entry does not, because it matches on the
|
|
3089
|
+
* FINAL segment, which is why the same sentence beginning `CLAUDE.md` always
|
|
3090
|
+
* classified as an ordinary workspace write.
|
|
3091
|
+
*
|
|
3092
|
+
* The test drawn here is the narrow one, and it is narrow in a stated way: the
|
|
3093
|
+
* word is prose only when the protected match came ENTIRELY from a
|
|
3094
|
+
* whitespace-free head. Concretely, all three hold:
|
|
3095
|
+
*
|
|
3096
|
+
* 1. the word contains whitespace at all;
|
|
3097
|
+
* 2. the first of its segments carrying whitespace is not the first segment,
|
|
3098
|
+
* so there is a head in front of it;
|
|
3099
|
+
* 3. that head, on its own, already classifies as the SAME surface the whole
|
|
3100
|
+
* word did.
|
|
3101
|
+
*
|
|
3102
|
+
* When (3) fails the word is kept, which is the fail-closed direction and is
|
|
3103
|
+
* what keeps the shapes below unmoved:
|
|
3104
|
+
*
|
|
3105
|
+
* - `my notes/CLAUDE.md` and any other exact-file match at any depth: the match
|
|
3106
|
+
* needs the final segment, the head classifies as nothing, and the word keeps
|
|
3107
|
+
* `policy.edit`. A real file named with a space therefore stays protected.
|
|
3108
|
+
* - `my notes/.github/workflows/x.yml`: whitespace BEFORE the protected run,
|
|
3109
|
+
* head `my notes` classifies as nothing, word unmoved.
|
|
3110
|
+
* - every word with no whitespace at all, which is the whole ordinary universe
|
|
3111
|
+
* of paths: condition (1) fails before anything is computed.
|
|
3112
|
+
*
|
|
3113
|
+
* What does move is the one shape prose and a path share exactly: a
|
|
3114
|
+
* directory-prefix match whose remainder carries whitespace. `cp x
|
|
3115
|
+
* ".approval/my file.txt"` is a real path of that shape and now takes the
|
|
3116
|
+
* command's own class. That is a deliberate trade and not an oversight —
|
|
3117
|
+
* `my file.txt` and `ci.yml should not gate this task` are the same string
|
|
3118
|
+
* shape, and no pure, disk-free rule tells them apart. A threshold on how much
|
|
3119
|
+
* whitespace the remainder carries would have rescued the first, and it is
|
|
3120
|
+
* rejected: a number here is folklore a second implementation has to guess at,
|
|
3121
|
+
* and the conformance suite would be pinning it. The write itself is not
|
|
3122
|
+
* unguarded: `core/protected-path-guard.ts` judges what a commit actually
|
|
3123
|
+
* changed, from the log rather than from a session, and a `.approval/` file
|
|
3124
|
+
* with no authorization record still fails the pull request.
|
|
3125
|
+
*
|
|
3126
|
+
* Pure and disk-free like the rest of this file: no resolution, no stat, no
|
|
3127
|
+
* question about whether anything exists.
|
|
3128
|
+
*/
|
|
3129
|
+
function proseNamingProtectedPath(word, surface, protectedPaths) {
|
|
3130
|
+
if (!/\s/u.test(word))
|
|
3131
|
+
return false;
|
|
3132
|
+
const segments = pathSegments(word);
|
|
3133
|
+
const firstSpaced = segments.findIndex((segment) => /\s/u.test(segment));
|
|
3134
|
+
// `-1` is a word whose whitespace survived no segment (nothing produces it
|
|
3135
|
+
// today) and `0` is whitespace in or before the first segment: both leave no
|
|
3136
|
+
// whitespace-free head to have matched, so both keep the word.
|
|
3137
|
+
if (firstSpaced <= 0)
|
|
3138
|
+
return false;
|
|
3139
|
+
const head = segments.slice(0, firstSpaced).join("/");
|
|
3140
|
+
return protectedPathClass(head, protectedPaths) === surface;
|
|
3141
|
+
}
|
|
2461
3142
|
/**
|
|
2462
3143
|
* The strictest protected surface named by these words, with the word itself.
|
|
2463
3144
|
*
|
|
@@ -2468,13 +3149,22 @@ function protectedRank(surface) {
|
|
|
2468
3149
|
* did and is what keeps a bare-string policy byte-identical: two routed paths
|
|
2469
3150
|
* in one segment are two equally consequential surfaces, and the segment's
|
|
2470
3151
|
* class names one of them while `ClassifiedSegment.path` names the word.
|
|
3152
|
+
*
|
|
3153
|
+
* `skipProse` turns on {@link proseNamingProtectedPath} (APRV-409) and is
|
|
3154
|
+
* passed by the POSITIONAL scan alone. The other caller here is the
|
|
3155
|
+
* write-redirection targets, whose words are paths by construction — the shell
|
|
3156
|
+
* is about to create that file — and the same is true of the paths
|
|
3157
|
+
* `core/apply-patch.ts` and the hook's file-tool pass hand to
|
|
3158
|
+
* {@link protectedPathClass} directly. None of them is offered the skip.
|
|
2471
3159
|
*/
|
|
2472
|
-
function strictestProtected(words, protectedPaths) {
|
|
3160
|
+
function strictestProtected(words, protectedPaths, skipProse = false) {
|
|
2473
3161
|
let best = null;
|
|
2474
3162
|
for (const word of words) {
|
|
2475
3163
|
const surface = protectedPathClass(word, protectedPaths);
|
|
2476
3164
|
if (surface === null)
|
|
2477
3165
|
continue;
|
|
3166
|
+
if (skipProse && proseNamingProtectedPath(word, surface, protectedPaths))
|
|
3167
|
+
continue;
|
|
2478
3168
|
if (best === null || protectedRank(surface) < protectedRank(best.surface)) {
|
|
2479
3169
|
best = { surface, path: word };
|
|
2480
3170
|
}
|
|
@@ -2504,6 +3194,18 @@ export const CODE_EXECUTING_RULES = [
|
|
|
2504
3194
|
"node-script",
|
|
2505
3195
|
/** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
|
|
2506
3196
|
"workspace-tool",
|
|
3197
|
+
/**
|
|
3198
|
+
* `npm pack` and `npm init <initializer>` (APRV-397).
|
|
3199
|
+
*
|
|
3200
|
+
* Both run code the runtime did not author, for different reasons. `npm pack`
|
|
3201
|
+
* runs the package's own `prepack` and `prepare` scripts, which are files in
|
|
3202
|
+
* the workspace an agent may have written a minute ago, exactly as `npm test`
|
|
3203
|
+
* is. `npm init <pkg>` downloads an initializer and runs it, which is
|
|
3204
|
+
* `npm exec` wearing another name. A bare `npm init` writes `package.json` and
|
|
3205
|
+
* runs nothing, so it is absent.
|
|
3206
|
+
*/
|
|
3207
|
+
"npm-pack",
|
|
3208
|
+
"npm-init-create",
|
|
2507
3209
|
/**
|
|
2508
3210
|
* Launching an agent harness, and probing one (APRV-354).
|
|
2509
3211
|
*
|
|
@@ -2698,11 +3400,18 @@ function sandboxWrapper(words, start) {
|
|
|
2698
3400
|
// can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
|
|
2699
3401
|
return null;
|
|
2700
3402
|
}
|
|
2701
|
-
/**
|
|
2702
|
-
|
|
3403
|
+
/**
|
|
3404
|
+
* Find the first table row matching this binary, subcommand and argv.
|
|
3405
|
+
*
|
|
3406
|
+
* The argv is read for one purpose only (APRV-397): a `probe` row matches an
|
|
3407
|
+
* argv that is nothing but version or help flags, and no other row reads it.
|
|
3408
|
+
*/
|
|
3409
|
+
function matchRule(bin, sub, args) {
|
|
2703
3410
|
for (const rule of COMMAND_RULES) {
|
|
2704
3411
|
if (!rule.bins.includes(bin))
|
|
2705
3412
|
continue;
|
|
3413
|
+
if (rule.probe === true && !isVersionProbe(args))
|
|
3414
|
+
continue;
|
|
2706
3415
|
if (rule.subs !== undefined) {
|
|
2707
3416
|
if (sub === null || !rule.subs.includes(sub))
|
|
2708
3417
|
continue;
|
|
@@ -2826,7 +3535,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2826
3535
|
if (credential !== null)
|
|
2827
3536
|
return { ok: true, ...credential };
|
|
2828
3537
|
const sub = positionals[0] ?? null;
|
|
2829
|
-
const rule = matchRule(basename, sub);
|
|
3538
|
+
const rule = matchRule(basename, sub, args);
|
|
2830
3539
|
if (rule === null) {
|
|
2831
3540
|
return {
|
|
2832
3541
|
ok: false,
|
|
@@ -2861,8 +3570,14 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2861
3570
|
// direction-blind — a copy OUT of the policy directory is as gated as a copy
|
|
2862
3571
|
// into it, because the classifier cannot tell which argument the binary will
|
|
2863
3572
|
// treat as the destination and guessing would be the ungated direction.
|
|
3573
|
+
//
|
|
3574
|
+
// This is the ONE scan that skips prose (APRV-409, `skipProse`): a positional
|
|
3575
|
+
// is whatever the author quoted, and an acceptance criterion that opens with
|
|
3576
|
+
// a directory path is a sentence rather than a file. Redirection targets
|
|
3577
|
+
// above, and the apply-patch and file-tool paths elsewhere, are paths by
|
|
3578
|
+
// construction and are not offered the skip.
|
|
2864
3579
|
if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
|
|
2865
|
-
const named = strictestProtected(positionals, protectedPaths);
|
|
3580
|
+
const named = strictestProtected(positionals, protectedPaths, true);
|
|
2866
3581
|
if (named !== null) {
|
|
2867
3582
|
return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
|
|
2868
3583
|
}
|
|
@@ -2989,6 +3704,43 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
2989
3704
|
* `classifyCommand` answers `unparseable` for. Segments carrying no binary (a
|
|
2990
3705
|
* bare assignment, a lone redirection) are omitted: they have no verb to show.
|
|
2991
3706
|
*/
|
|
3707
|
+
/**
|
|
3708
|
+
* The paths each segment WRITES through a redirection, from the same parse
|
|
3709
|
+
* (APRV-445). `<` is a read and is left out, as are the discard devices.
|
|
3710
|
+
* `null` when the tokenizer refuses the string.
|
|
3711
|
+
*/
|
|
3712
|
+
export function commandSegmentWriteTargets(command) {
|
|
3713
|
+
const lexed = lex(command);
|
|
3714
|
+
if (!lexed.ok)
|
|
3715
|
+
return null;
|
|
3716
|
+
return lexed.segments.map((segment) => segment.redirects
|
|
3717
|
+
.filter((redirect) => redirect.op !== "<")
|
|
3718
|
+
.map((redirect) => redirect.target.text)
|
|
3719
|
+
.filter((target) => !isDiscardTarget(target)));
|
|
3720
|
+
}
|
|
3721
|
+
export function commandSegmentShapes(command) {
|
|
3722
|
+
const lexed = lex(command);
|
|
3723
|
+
if (!lexed.ok)
|
|
3724
|
+
return null;
|
|
3725
|
+
return lexed.segments.map((segment) => {
|
|
3726
|
+
const words = segment.words.map((word) => word.text);
|
|
3727
|
+
let cursor = 0;
|
|
3728
|
+
while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
|
|
3729
|
+
cursor += 1;
|
|
3730
|
+
return {
|
|
3731
|
+
text: segment.text,
|
|
3732
|
+
bin: words[cursor] ?? null,
|
|
3733
|
+
args: words.slice(cursor + 1),
|
|
3734
|
+
writes: segment.redirects
|
|
3735
|
+
.filter((redirect) => redirect.op !== "<")
|
|
3736
|
+
.map((redirect) => redirect.target.text)
|
|
3737
|
+
.filter((target) => !isDiscardTarget(target)),
|
|
3738
|
+
reads: segment.redirects
|
|
3739
|
+
.filter((redirect) => redirect.op === "<")
|
|
3740
|
+
.map((redirect) => redirect.target.text),
|
|
3741
|
+
};
|
|
3742
|
+
});
|
|
3743
|
+
}
|
|
2992
3744
|
export function commandSegmentWords(command) {
|
|
2993
3745
|
const lexed = lex(command);
|
|
2994
3746
|
if (!lexed.ok)
|