@nodii/dev 0.6.36 → 0.6.37
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 +32 -3
- package/dist/api.js +90 -4
- package/dist/cli.js +32 -6
- package/dist/core.js +1 -1
- package/dist/nodii-dev.js +13 -8
- package/dist/safety.js +112 -1
- package/dist/stdout.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -327,6 +327,29 @@ nodii-dev hook nudge-pre # guideline nudges before a tool call; nev
|
|
|
327
327
|
nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
|
|
328
328
|
nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
|
|
329
329
|
nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`hook safety-pre` (installed by `harness install` for Claude Code and Codex)
|
|
333
|
+
blocks, silently when satisfied (AGENT-HARNESS-15):
|
|
334
|
+
|
|
335
|
+
- **Everyone, in a lane or not:** pattern kills (`pkill`, `killall`,
|
|
336
|
+
`kill $(pgrep …)`, `ps … | xargs kill`, `kill -1`). Kill only a PID you
|
|
337
|
+
started and saved.
|
|
338
|
+
- **In a lane:** `git stash`; checking out or creating another branch;
|
|
339
|
+
`git add -A`; `git reset` onto another branch; `git worktree remove --force`;
|
|
340
|
+
a force push (`-f`, `--force`, `--force-with-lease`, `+ref`); a push without
|
|
341
|
+
a review receipt for HEAD and origin/main; `--no-verify` and its aliases
|
|
342
|
+
(`LEFTHOOK=0`, `HUSKY=0`, `core.hooksPath`); `nodii-dev login`, `logout`,
|
|
343
|
+
`agent register`; `nodii/prod` secrets, `NODE_ENV=test`, `skipAuth`;
|
|
344
|
+
`docker compose down -v` or volume deletes on stacks the run didn't create;
|
|
345
|
+
deploy/apply without a `GO deploy <sha>` grant; shell writes outside the
|
|
346
|
+
worktree (redirects, `tee`, `cp`, `mv`, `rm`, `mkdir`, `touch` …; temp space
|
|
347
|
+
is fine) and mutating `git -C` into another checkout; Edit/Write outside the
|
|
348
|
+
worktree, outside owned paths or on generated files.
|
|
349
|
+
- **A coordinator** may use `--no-verify` only with `NODII_DEV_NO_VERIFY_REASON`
|
|
350
|
+
set; the reason is recorded in private Git metadata.
|
|
351
|
+
|
|
352
|
+
```bash
|
|
330
353
|
|
|
331
354
|
nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
|
|
332
355
|
--side backend|frontend|both [--repo REPO] [--issue ISSUE]
|
|
@@ -996,9 +1019,15 @@ nodii-dev requests resolve UUID --commit-sha abc1234 --resolution-md "shipped"
|
|
|
996
1019
|
nodii-dev open-questions answer UUID --decision-id D1420
|
|
997
1020
|
```
|
|
998
1021
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1022
|
+
`api` and the resource commands read Dev's live catalog through the edge
|
|
1023
|
+
(`GET /devops/api/v1/openapi.json`), so an operation Dev adds after a CLI
|
|
1024
|
+
release is callable at once. The catalog is cached for 15 minutes in
|
|
1025
|
+
`~/.cache/nodii-dev/api-catalog.json` (`NODII_DEV_API_CATALOG_CACHE`),
|
|
1026
|
+
stamped with Dev's version and the document's sha256. When Dev cannot be read
|
|
1027
|
+
the CLI uses the last good copy, else the OpenAPI document bundled at release
|
|
1028
|
+
(the edge's routes only, so it works offline). `api catalog [--refresh]` shows
|
|
1029
|
+
which one is in use and why; `NODII_DEV_API_CATALOG=bundled` never asks Dev.
|
|
1030
|
+
The first-class resource families cover every tag: services,
|
|
1002
1031
|
feature docs, contracts (gRPC, REST, outbox, sagas, tables), libraries,
|
|
1003
1032
|
workers, projects, slices, SQL lookups, issues, backlogs, builds, changes,
|
|
1004
1033
|
runtime, people, agents, inbox/gates, design, runbooks,
|
package/dist/api.js
CHANGED
|
@@ -1,16 +1,102 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
2
5
|
import { stdin } from "node:process";
|
|
3
|
-
import { CliError, devRawRequest, encode } from "./core.js";
|
|
6
|
+
import { CliError, VERSION, devExchange, devRawRequest, encode, readConfig } from "./core.js";
|
|
4
7
|
|
|
5
8
|
const methods = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
6
|
-
// The Dev API as the platform edge routes it, bundled with the package
|
|
7
|
-
//
|
|
9
|
+
// The Dev API as the platform edge routes it, bundled with the package: the
|
|
10
|
+
// fallback when Dev's live catalog cannot be read. Refresh: `npm run openapi:snapshot`.
|
|
8
11
|
let bundled;
|
|
9
12
|
export async function loadOpenApi() {
|
|
10
13
|
bundled ??= JSON.parse(await readFile(new URL("./devops-openapi.json", import.meta.url), "utf8"));
|
|
11
14
|
return bundled;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
// Dev issue 99: an operation Dev added after this release is still callable.
|
|
18
|
+
// The live catalog is read through the edge, cached for CATALOG_TTL_MS per
|
|
19
|
+
// edge and stamped with Dev's version and the document's sha256. A refused or
|
|
20
|
+
// malformed read is cached too, so a Dev without the route costs one request
|
|
21
|
+
// per TTL. Unreachable or signed out: the last good copy, else the bundled one.
|
|
22
|
+
export const LIVE_CATALOG_PATH = "/api/v1/openapi.json";
|
|
23
|
+
export const CATALOG_TTL_MS = 15 * 60 * 1000;
|
|
24
|
+
const CATALOG_TIMEOUT_MS = 4000;
|
|
25
|
+
const TRANSIENT = new Set(["DEV_UNREACHABLE", "DEV_TIMEOUT", "NOT_LOGGED_IN", "LOGIN_REQUIRED", "OAUTH_LOGIN_REQUIRED", "KEYCHAIN_UNAVAILABLE"]);
|
|
26
|
+
export const catalogCachePath = (env = process.env) =>
|
|
27
|
+
env.NODII_DEV_API_CATALOG_CACHE || join(homedir(), ".cache", "nodii-dev", "api-catalog.json");
|
|
28
|
+
|
|
29
|
+
const isSpec = (value) => value && typeof value === "object" && typeof value.openapi === "string" && value.paths && typeof value.paths === "object";
|
|
30
|
+
// Only Dev's own API is reachable through the edge's /devops prefix.
|
|
31
|
+
const edgeReachable = (spec) => ({ ...spec, paths: Object.fromEntries(Object.entries(spec.paths).filter(([path]) => path.startsWith("/api/v1/"))) });
|
|
32
|
+
|
|
33
|
+
async function readCache(path) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeCache(path, value) {
|
|
42
|
+
try {
|
|
43
|
+
await mkdir(dirname(path), { recursive: true });
|
|
44
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
45
|
+
await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
46
|
+
await rename(tmp, path);
|
|
47
|
+
} catch {
|
|
48
|
+
// A cache that cannot be written only costs a refetch next time.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const bundledCatalog = async (reason) => ({ spec: await loadOpenApi(), source: "bundled", version: `@nodii/dev ${VERSION}`, reason });
|
|
53
|
+
const cachedCatalog = (good, source, reason) => ({ spec: good.spec, source, version: good.version, sha256: good.sha256, fetchedAt: good.fetchedAt, reason });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The operation catalog: `{ spec, source: live|cache|stale|bundled, version, sha256?, fetchedAt?, reason? }`.
|
|
57
|
+
* NODII_DEV_API_CATALOG=bundled skips Dev; `refresh` ignores the TTL.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadCatalog({ refresh = false, env = process.env, now = Date.now() } = {}) {
|
|
60
|
+
if (env.NODII_DEV_API_CATALOG === "bundled") return bundledCatalog("NODII_DEV_API_CATALOG=bundled");
|
|
61
|
+
const config = await readConfig(false).catch(() => null);
|
|
62
|
+
if (!config?.edgeUrl) return bundledCatalog("not signed in");
|
|
63
|
+
const path = catalogCachePath(env);
|
|
64
|
+
const cache = await readCache(path);
|
|
65
|
+
const mine = cache?.edgeUrl === config.edgeUrl ? cache : null;
|
|
66
|
+
const fresh = mine && now - Date.parse(mine.checkedAt) < CATALOG_TTL_MS;
|
|
67
|
+
if (fresh && !refresh)
|
|
68
|
+
return mine.failure
|
|
69
|
+
? mine.good ? cachedCatalog(mine.good, "stale", mine.failure) : bundledCatalog(mine.failure)
|
|
70
|
+
: cachedCatalog(mine.good, "cache");
|
|
71
|
+
|
|
72
|
+
let failure;
|
|
73
|
+
try {
|
|
74
|
+
const { payload } = await devExchange("GET", LIVE_CATALOG_PATH, undefined, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
|
|
75
|
+
if (!isSpec(payload)) failure = `Dev's ${LIVE_CATALOG_PATH} is not an OpenAPI document`;
|
|
76
|
+
else {
|
|
77
|
+
const spec = edgeReachable(payload);
|
|
78
|
+
const sha256 = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
|
|
79
|
+
const good = { spec, version: `Dev ${payload.info?.version ?? "unknown"}`, sha256, fetchedAt: new Date(now).toISOString() };
|
|
80
|
+
await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: good.fetchedAt, good, failure: null });
|
|
81
|
+
return cachedCatalog(good, "live");
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const reason = `${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`;
|
|
85
|
+
// Signed out, offline, timed out or a 5xx says nothing about the route: not cached.
|
|
86
|
+
if (TRANSIENT.has(error?.code) || /^HTTP_5\d\d$/.test(error?.code ?? "") || !(error instanceof CliError))
|
|
87
|
+
return mine?.good ? cachedCatalog(mine.good, "stale", reason) : bundledCatalog(reason);
|
|
88
|
+
failure = reason;
|
|
89
|
+
}
|
|
90
|
+
await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: new Date(now).toISOString(), good: mine?.good ?? null, failure });
|
|
91
|
+
return mine?.good ? cachedCatalog(mine.good, "stale", failure) : bundledCatalog(failure);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function describeCatalog(catalog) {
|
|
95
|
+
const stamp = [catalog.version, catalog.sha256 && `sha256 ${catalog.sha256.slice(0, 12)}`, catalog.fetchedAt && `fetched ${catalog.fetchedAt}`].filter(Boolean).join(", ");
|
|
96
|
+
const why = catalog.reason ? `; live catalog unavailable: ${catalog.reason}` : "";
|
|
97
|
+
return `catalog: ${catalog.source} (${stamp})${why}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
14
100
|
export function operationsFrom(spec) {
|
|
15
101
|
const out = [];
|
|
16
102
|
for (const [path, item] of Object.entries(spec.paths ?? {}))
|
package/dist/cli.js
CHANGED
|
@@ -67,7 +67,8 @@ import {
|
|
|
67
67
|
findFirstClass,
|
|
68
68
|
flagsToOperation,
|
|
69
69
|
refusePersonOnly,
|
|
70
|
-
|
|
70
|
+
describeCatalog,
|
|
71
|
+
loadCatalog,
|
|
71
72
|
operationsFrom,
|
|
72
73
|
parseParams,
|
|
73
74
|
readBody,
|
|
@@ -111,7 +112,7 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
111
112
|
# blocked and wont_do need --reason; done needs --dev-check
|
|
112
113
|
inbox list|show
|
|
113
114
|
drift file | request file
|
|
114
|
-
decisions list|get|search
|
|
115
|
+
decisions list|get|search # get DEC-n reads the AD decision with its amends
|
|
115
116
|
design entries|entry|guide
|
|
116
117
|
design guideline list [--drafted] [--all] | show ENTRY
|
|
117
118
|
design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
|
|
@@ -232,6 +233,9 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
232
233
|
|
|
233
234
|
api list [--tag TAG] # staff-only operations are marked (ratifyAdDocument:
|
|
234
235
|
# agents get OPERATOR_REQUIRED by design)
|
|
236
|
+
api catalog [--refresh] # which catalog api and resource commands use: Dev's live one
|
|
237
|
+
# (cached 15 min, version + sha256 stamped) or the bundled
|
|
238
|
+
# fallback, and why; NODII_DEV_API_CATALOG=bundled skips Dev
|
|
235
239
|
api <operationId> [--param k=v] [--body @file|-|JSON]
|
|
236
240
|
<resource> <action> [ID ...] [--field VALUE]
|
|
237
241
|
|
|
@@ -560,8 +564,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
560
564
|
const [command, action, ...rest] = args;
|
|
561
565
|
if (!command || f.help || command === "help") return console.log(HELP);
|
|
562
566
|
if (command === "api") {
|
|
563
|
-
const
|
|
567
|
+
const catalog = await loadCatalog({ refresh: action === "catalog" && f.refresh === true });
|
|
568
|
+
const operations = operationsFrom(catalog.spec);
|
|
569
|
+
if (action === "catalog") {
|
|
570
|
+
const { spec: _spec, ...stamp } = catalog;
|
|
571
|
+
return emit({ ...stamp, operations: operations.length }, json, `${describeCatalog(catalog)}\n${operations.length} operations`);
|
|
572
|
+
}
|
|
564
573
|
if (action === "list") {
|
|
574
|
+
if (!json) console.error(`# ${describeCatalog(catalog)}`);
|
|
565
575
|
const selected = f.tag === undefined || f.tag === true
|
|
566
576
|
? operations
|
|
567
577
|
: operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
|
|
@@ -570,7 +580,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
570
580
|
}
|
|
571
581
|
if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
|
|
572
582
|
const entry = operations.find((x) => x.operationId === action);
|
|
573
|
-
if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
|
|
583
|
+
if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action} (${describeCatalog(catalog)})`, 2);
|
|
574
584
|
refusePersonOnly(entry.operationId);
|
|
575
585
|
const parsed = await readBody(f.body);
|
|
576
586
|
const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
|
|
@@ -832,6 +842,21 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
832
842
|
f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
|
|
833
843
|
}
|
|
834
844
|
if (command === "decisions") {
|
|
845
|
+
// A DEC-* id is an AD-era decision: getAdDecision carries its amends; the
|
|
846
|
+
// pre-dev /decisions route knows none of them.
|
|
847
|
+
if (action === "get" && /^DEC-\d+$/i.test(rest[0] ?? ""))
|
|
848
|
+
return emit(
|
|
849
|
+
await devRequest("GET", `/api/v1/ad-decisions/${encode(rest[0].toUpperCase())}`),
|
|
850
|
+
json,
|
|
851
|
+
({ data: d }) => [
|
|
852
|
+
`${d.id}\t${d.status}\t${d.title}`,
|
|
853
|
+
...(d.ruling ? ["", d.ruling] : []),
|
|
854
|
+
"",
|
|
855
|
+
d.amends?.length
|
|
856
|
+
? `Amends (${d.amends.length}):\n${d.amends.map((a) => ` ${a.clauseId ?? a.docCode}\t${a.effect}\t${a.docCode} ${a.previousVersion ?? "-"} -> ${a.version}${a.documentTitle ? `\t${a.documentTitle}` : ""}`).join("\n")}`
|
|
857
|
+
: "Amends: none",
|
|
858
|
+
].join("\n"),
|
|
859
|
+
);
|
|
835
860
|
if (action === "get")
|
|
836
861
|
return emit(
|
|
837
862
|
await devRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
|
|
@@ -1084,7 +1109,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1084
1109
|
}
|
|
1085
1110
|
}
|
|
1086
1111
|
if (Object.hasOwn(GROUPS, command)) {
|
|
1087
|
-
const
|
|
1112
|
+
const catalog = await loadCatalog();
|
|
1113
|
+
const operations = operationsFrom(catalog.spec);
|
|
1088
1114
|
let entry = findFirstClass(operations, command, action);
|
|
1089
1115
|
if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
|
|
1090
1116
|
entry = operations.find((x) => x.operationId === "acceptDrift");
|
|
@@ -1099,7 +1125,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1099
1125
|
if (!entry) {
|
|
1100
1126
|
const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
|
|
1101
1127
|
const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
|
|
1102
|
-
throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
|
|
1128
|
+
throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available} (${describeCatalog(catalog)})`, 2);
|
|
1103
1129
|
}
|
|
1104
1130
|
const built = flagsToOperation(entry, rest, f);
|
|
1105
1131
|
if (command === "requests" && action === "answer")
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.37";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
package/dist/nodii-dev.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { fail, main } from "./cli.js";
|
|
3
3
|
import { regroundCurrentLane, shouldReground } from "./freeze.js";
|
|
4
4
|
import { isHookDenial } from "./hooks.js";
|
|
5
|
+
import { drainStdout, routeStdout } from "./stdout.js";
|
|
6
|
+
|
|
7
|
+
routeStdout();
|
|
5
8
|
|
|
6
9
|
// Hooks run in the agent's environment, which may be incomplete or may not be
|
|
7
10
|
// a git worktree. An unavailable hook must never turn into a refused tool call.
|
|
@@ -16,11 +19,13 @@ async function run() {
|
|
|
16
19
|
await main(argv);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
run()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
run()
|
|
23
|
+
.catch((error) => {
|
|
24
|
+
const denial = isHookDenial(error?.code);
|
|
25
|
+
if (argv[0] === "hook" && !denial) {
|
|
26
|
+
process.exitCode = 0;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
fail(error);
|
|
30
|
+
})
|
|
31
|
+
.finally(drainStdout);
|
package/dist/safety.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
@@ -109,7 +110,103 @@ function createsBranch(after) {
|
|
|
109
110
|
return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
// A hook bypass by another name is still --no-verify: lefthook's and husky's
|
|
114
|
+
// off switches, and pointing Git's hooks somewhere else.
|
|
115
|
+
const HOOK_BYPASS = /(?:^|[\s;&|(])(?:export\s+|env\s+)?(?:LEFTHOOK\s*=\s*["']?(?:0|false|off)\b|HUSKY\s*=\s*["']?0\b)|\bcore\.hooks[Pp]ath\b/;
|
|
116
|
+
const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify")) || HOOK_BYPASS.test(command);
|
|
117
|
+
|
|
118
|
+
// Operator, 29 Oct: a pattern kill on macOS once killed every agent on the
|
|
119
|
+
// machine. Only a PID the caller started (and saved) may be killed.
|
|
120
|
+
const AT_COMMAND = String.raw`(?:^|[;&|(\x60{\n]|\$\()\s*(?:(?:sudo|exec|nohup|time|command)(?:\s+-\S+)*\s+)*`;
|
|
121
|
+
const PATTERN_KILLS = [
|
|
122
|
+
new RegExp(`${AT_COMMAND}(?:\\S*/)?(?:pkill|killall)\\b`),
|
|
123
|
+
/(?:^|[;&|(\s])kill\b[^;&|\n]*(?:\$\(|`)\s*(?:\S*\/)?(?:pgrep|pidof|ps|lsof|jobs)\b/,
|
|
124
|
+
/\b(?:pgrep|pidof|ps|lsof)\b[^;&\n]*\|(?:[^;&\n]*\|)?\s*xargs\b[^;&|\n]*\bkill\b/,
|
|
125
|
+
/(?:^|[;&|(\s])kill\s+(?:-(?:\d+|[A-Z]+|s\s+\S+)\s+)*(?:--\s+)?-1\b/,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Blocks that hold for every caller, in a lane or not, and before any lane discovery. */
|
|
129
|
+
export function machineViolations(command) {
|
|
130
|
+
if (PATTERN_KILLS.some((pattern) => pattern.test(command)))
|
|
131
|
+
return [{ code: "SAFETY_PATTERN_KILL", message: "Pattern kills (pkill, killall, kill $(pgrep ...), ps | xargs kill, kill -1) can kill every agent on this machine. Kill only a PID you started and saved to a .pid file." }];
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function forcePush(invocation) {
|
|
136
|
+
if (invocation.verb !== "push") return false;
|
|
137
|
+
return invocation.args.some((arg) => ["-f", "--force", "--force-if-includes", "--mirror"].includes(arg) || arg.startsWith("--force-with-lease") || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg) || /^\+[^\s]/.test(arg));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Writes a lane may make outside its worktree: temporary and scratch space.
|
|
141
|
+
const WRITABLE_ELSEWHERE = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders", "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/tty"];
|
|
142
|
+
const WRITE_COMMANDS = new Map([["cp", "last"], ["mv", "last"], ["ln", "last"], ["install", "last"], ["rsync", "last"], ["rm", "all"], ["rmdir", "all"], ["touch", "all"], ["mkdir", "all"], ["truncate", "all"], ["chmod", "rest"], ["chown", "rest"], ["tee", "all"]]);
|
|
143
|
+
const READ_ONLY_GIT = new Set(["status", "diff", "log", "show", "rev-parse", "rev-list", "branch", "ls-files", "ls-tree", "cat-file", "describe", "blame", "grep", "merge-base", "config", "remote", "fetch", "worktree", "for-each-ref", "symbolic-ref", "shortlog", "reflog", "name-rev", "check-ignore", "var", "help", "version"]);
|
|
144
|
+
|
|
145
|
+
/** Shell words with their quoting kept, split on control operators, for write detection. */
|
|
146
|
+
function segments(command) {
|
|
147
|
+
const out = [[]];
|
|
148
|
+
const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|\n])|([^\s"';|&]+(?:&(?![&])[^\s"';|&]*)*)|(&)/g;
|
|
149
|
+
for (const match of command.matchAll(re)) {
|
|
150
|
+
if (match[3] || match[5]) out.push([]);
|
|
151
|
+
else out.at(-1).push({ text: match[1] ?? match[2] ?? match[4], quoted: match[4] === undefined });
|
|
152
|
+
}
|
|
153
|
+
return out.filter((x) => x.length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeTargets(command) {
|
|
157
|
+
const targets = [];
|
|
158
|
+
for (const segment of segments(command)) {
|
|
159
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
160
|
+
const { text, quoted } = segment[i];
|
|
161
|
+
if (quoted) continue;
|
|
162
|
+
const redirect = /^(?:\d*|&)>{1,2}\|?(.*)$/.exec(text);
|
|
163
|
+
if (redirect) {
|
|
164
|
+
const target = redirect[1] || segment[i + 1]?.text;
|
|
165
|
+
if (target && !target.startsWith("&")) targets.push(target);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const words = segment.filter((x, i) => !(i === 0 && /^[A-Z_][A-Z0-9_]*=/.test(x.text)));
|
|
169
|
+
const at = words.findIndex((x) => !["sudo", "command", "exec", "nohup"].includes(x.text));
|
|
170
|
+
const name = basename(words[at]?.text ?? "");
|
|
171
|
+
const mode = WRITE_COMMANDS.get(name);
|
|
172
|
+
if (!mode) continue;
|
|
173
|
+
const args = words.slice(at + 1).map((x) => x.text).filter((x) => !x.startsWith("-") && !/^\d*>/.test(x));
|
|
174
|
+
if (mode === "last") targets.push(...args.slice(-1));
|
|
175
|
+
else if (mode === "rest") targets.push(...args.slice(1));
|
|
176
|
+
else targets.push(...args);
|
|
177
|
+
}
|
|
178
|
+
return targets;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function outsideWorktree(target, context, relativeKnown) {
|
|
182
|
+
let path = target;
|
|
183
|
+
if (path === "~" || path.startsWith("~/")) path = join(homedir(), path.slice(1));
|
|
184
|
+
else if (/^\$\{?HOME\}?(?:\/|$)/.test(path)) path = join(homedir(), path.replace(/^\$\{?HOME\}?/, ""));
|
|
185
|
+
if (path.includes("$") || path.includes("`")) return false;
|
|
186
|
+
if (!isAbsolute(path)) {
|
|
187
|
+
if (!relativeKnown) return false;
|
|
188
|
+
path = resolve(context.cwd, path);
|
|
189
|
+
}
|
|
190
|
+
const inside = (root) => { const rel = relative(root, path); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
|
|
191
|
+
return !inside(context.root) && !WRITABLE_ELSEWHERE().some(inside);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function gitOutsideWorktree(tokens, context) {
|
|
195
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
196
|
+
if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
|
|
197
|
+
let cursor = at + 1;
|
|
198
|
+
let target = null;
|
|
199
|
+
while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
|
|
200
|
+
const option = tokens[cursor];
|
|
201
|
+
if (["-C", "--git-dir", "--work-tree"].includes(option)) target = tokens[cursor + 1] ?? null;
|
|
202
|
+
else if (/^--(?:git-dir|work-tree)=/.test(option)) target = option.slice(option.indexOf("=") + 1);
|
|
203
|
+
cursor += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option) ? 2 : 1;
|
|
204
|
+
}
|
|
205
|
+
const verb = tokens[cursor];
|
|
206
|
+
if (target && verb && !READ_ONLY_GIT.has(verb) && outsideWorktree(target, context, true)) return { target, verb };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
113
210
|
|
|
114
211
|
function deployCommand(tokens, command) {
|
|
115
212
|
if (commandHas(tokens, "tofu", "apply")) return true;
|
|
@@ -189,6 +286,16 @@ export function commandViolations(command, context) {
|
|
|
189
286
|
}
|
|
190
287
|
if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
|
|
191
288
|
block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
|
|
289
|
+
if (gitCommands.some(forcePush))
|
|
290
|
+
block("SAFETY_FORCE_PUSH", "A force push rewrites history others may hold; a lane pushes fast-forward only. Ask the coordinator.");
|
|
291
|
+
if (context.root) {
|
|
292
|
+
const other = gitOutsideWorktree(tokens, context);
|
|
293
|
+
if (other) block("SAFETY_OTHER_WORKTREE", `git ${other.verb} in ${other.target} touches a checkout outside this lane's worktree ${context.root}.`);
|
|
294
|
+
const relativeKnown = !/(?:^|[;&|(\s])(?:cd|pushd)\s/.test(command);
|
|
295
|
+
const outside = writeTargets(command).filter((target) => outsideWorktree(target, context, relativeKnown));
|
|
296
|
+
if (outside.length)
|
|
297
|
+
block("SAFETY_WRITE_OUTSIDE_WORKTREE", `Write blocked outside the lane worktree ${context.root}: ${[...new Set(outside)].join(", ")}. Use the worktree or a temp/scratch directory.`);
|
|
298
|
+
}
|
|
192
299
|
|
|
193
300
|
if (usesNoVerify(command)) {
|
|
194
301
|
if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
|
|
@@ -387,6 +494,10 @@ function blocked(violation) {
|
|
|
387
494
|
export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
|
|
388
495
|
const tool = payload.tool_name ?? "";
|
|
389
496
|
if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
|
|
497
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
498
|
+
const machine = machineViolations(String(payload.tool_input?.command ?? ""))[0];
|
|
499
|
+
if (machine) blocked(machine);
|
|
500
|
+
}
|
|
390
501
|
const cwd = payload.cwd ?? process.cwd();
|
|
391
502
|
const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
|
|
392
503
|
context.cwd = await realpath(cwd).catch(() => resolve(cwd));
|
package/dist/stdout.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { format } from "node:util";
|
|
2
|
+
|
|
3
|
+
// Dev issue 37: on Bun, once any module imports from `node:process`,
|
|
4
|
+
// console.log to a pipe stops at 65,536 bytes and the rest is lost at exit, so
|
|
5
|
+
// a large `--json` reached `jq` cut off. process.stdout.write delivers it all.
|
|
6
|
+
// The executable routes console.log through it and waits for the stream to
|
|
7
|
+
// drain before it returns. A reader that closes early (`| head -c`) is not an
|
|
8
|
+
// error.
|
|
9
|
+
let installed = false;
|
|
10
|
+
|
|
11
|
+
export function routeStdout(stream = process.stdout) {
|
|
12
|
+
if (installed) return;
|
|
13
|
+
installed = true;
|
|
14
|
+
stream.on("error", (error) => {
|
|
15
|
+
if (error?.code !== "EPIPE") throw error;
|
|
16
|
+
stream.destroy();
|
|
17
|
+
});
|
|
18
|
+
console.log = (...values) => {
|
|
19
|
+
if (!stream.destroyed) stream.write(`${format(...values)}\n`);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function drainStdout(stream = process.stdout) {
|
|
24
|
+
if (stream.destroyed || !stream.writable) return Promise.resolve();
|
|
25
|
+
return new Promise((resolve) => stream.write("", () => resolve()));
|
|
26
|
+
}
|