failproofai 1.0.8-beta.0 → 1.0.9-beta.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +3 -3
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14o3ek1._.js → [root-of-the-server]__0tmz862._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0o4xkpl._.js → _0n0lja6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1-7sqrb._.js → _1p3iz2d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
- package/.next/standalone/.next/static/chunks/{2z7i-yg59w4pn.js → 0247hc9ath5t-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0ahfwmbkpgfw4.js → 03001k9jy-kst.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2vo7qbdbvnc0o.js → 06k7gsfnb7jkh.js} +1 -1
- package/.next/standalone/.next/static/chunks/0qejem4-zt-hy.js +1 -0
- package/.next/standalone/.next/static/chunks/{1v_tp3hm8wyhe.js → 0snylg3y1i47a.js} +1 -1
- package/.next/standalone/.next/static/chunks/1h0if8ky362as.js +1 -0
- package/.next/standalone/.next/static/chunks/{3bgot5v6f6s3c.js → 2qsrrrl2maqsb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0bhidk90e-07f.css → 3_e_wg5_oay87.css} +1 -1
- package/.next/standalone/.next/static/chunks/3ns-egfh8i8i5.js +6 -0
- package/.next/standalone/.next/static/chunks/3rq7fjj9we0v3.js +1 -0
- package/.next/standalone/.next/static/chunks/42m0f2hh8vfb1.js +1 -0
- package/.next/standalone/app/actions/get-jev-config.ts +2 -2
- package/.next/standalone/app/actions/update-jev-config.ts +12 -9
- package/.next/standalone/app/components/jev-notices.tsx +10 -10
- package/.next/standalone/app/settings/jev-panel.tsx +11 -11
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +2 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +26 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +27 -0
- package/.next/standalone/hermes-plugin/README.md +18 -8
- package/.next/standalone/package.json +10 -10
- package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
- package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +33 -7
- package/dist/cli.mjs +664 -553
- package/dist/worker.mjs +164 -407
- package/hermes-plugin/README.md +18 -8
- package/package.json +10 -10
- package/scripts/build-policy-pack.mjs +6 -9
- package/src/hooks/cloud-connection.ts +12 -6
- package/src/hooks/custom-hooks-loader.ts +1 -1
- package/src/hooks/effective-reviewers.ts +50 -43
- package/src/hooks/handler.ts +25 -16
- package/src/hooks/hermes-update.ts +74 -0
- package/src/hooks/hook-activity-store.ts +3 -3
- package/src/hooks/integrations.ts +349 -55
- package/src/hooks/jev-activity.ts +3 -3
- package/src/hooks/jev-cli.ts +17 -17
- package/src/hooks/jev-cloud-connection.ts +4 -4
- package/src/hooks/manager.ts +3 -2
- package/src/hooks/pack-cli.ts +24 -23
- package/src/hooks/pack-manifest.ts +25 -2
- package/src/hooks/pack-store.ts +2 -2
- package/src/hooks/policy-authority.ts +51 -32
- package/src/hooks/policy-evaluator.ts +5 -5
- package/src/hooks/policy-registry.ts +5 -7
- package/src/hooks/policy-reviewability.ts +26 -10
- package/src/hooks/policy-types.ts +4 -4
- package/src/hooks/semantic/combine.ts +13 -13
- package/src/hooks/semantic/decide.ts +134 -29
- package/src/hooks/semantic/evaluator.ts +5 -5
- package/src/hooks/semantic/facts.ts +91 -2
- package/src/hooks/semantic/jev-client.ts +1 -1
- package/src/hooks/semantic/jev-config.ts +21 -12
- package/src/hooks/semantic/jev-review.ts +3 -3
- package/src/hooks/semantic/jev-stats.ts +17 -17
- package/src/hooks/semantic/pack-policies.ts +48 -48
- package/src/hooks/semantic/policies.ts +13 -425
- package/src/hooks/semantic/precondition-names.ts +1 -1
- package/src/hooks/semantic/preconditions.ts +1 -1
- package/src/hooks/semantic/types.ts +7 -1
- package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +0 -1
- package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +0 -1
- package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +0 -1
- package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +0 -1
- package/.next/standalone/.next/static/chunks/3o3f1ibfci0p7.js +0 -6
- /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{O_b5R1axf5cb4NxIXq2kS → z1C0kJVF0NCtWn5R88hW3}/_ssgManifest.js +0 -0
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* | Jev answered | reviewable denies/instructs Jev covered are cleared; final =|
|
|
23
23
|
* | | the most severe of {remaining regex results, Jev's verdict} |
|
|
24
24
|
*
|
|
25
|
-
* `
|
|
25
|
+
* `observe` mode computes and records all of it, and still returns the regex
|
|
26
26
|
* result.
|
|
27
27
|
*
|
|
28
28
|
* ## Where this departs from plan §4
|
|
@@ -232,14 +232,14 @@ import type { PolicyAuthority } from "../policy-types";
|
|
|
232
232
|
// The only value imports, and deliberately from the activity vocabulary rather
|
|
233
233
|
// than local literals: the fallback reason this module writes itself has to
|
|
234
234
|
// be a code the activity store's closed list names, or it is stored and
|
|
235
|
-
// shipped as `other`, and
|
|
235
|
+
// shipped as `other`, and an observe-mode verdict's model id has to pass the same
|
|
236
236
|
// shape check the row's own `jevModel` does. Importing them makes a rename a
|
|
237
237
|
// compile error here. `jev-activity.ts` is pure (no node imports, no semantic
|
|
238
238
|
// modules), so this costs the hook path nothing.
|
|
239
239
|
import { JEV_MODEL_RE, JEV_REASON_REQUEST_CUT } from "../jev-activity";
|
|
240
240
|
|
|
241
241
|
export type Decision = "allow" | "deny" | "instruct";
|
|
242
|
-
export type JevMode = "
|
|
242
|
+
export type JevMode = "observe" | "enforce";
|
|
243
243
|
|
|
244
244
|
/** One regex policy's verdict, in evaluation order. */
|
|
245
245
|
export interface RegexVerdict {
|
|
@@ -383,13 +383,13 @@ export interface JevActivityFields {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
/**
|
|
386
|
-
* Jev's own deny or instruct in
|
|
386
|
+
* Jev's own deny or instruct in OBSERVE mode — the verdict enforce mode would
|
|
387
387
|
* have applied, recorded rather than applied. The handler files it in the
|
|
388
388
|
* activity row's `observed` list, the "would have" record observe-mode cloud
|
|
389
389
|
* and pack policies already use, so FailproofAI Cloud's policy page counts it
|
|
390
390
|
* with no change on its side.
|
|
391
391
|
*/
|
|
392
|
-
export interface
|
|
392
|
+
export interface ObserveVerdict {
|
|
393
393
|
/** `semantic/<check>` — the name enforce mode would have attributed it to. */
|
|
394
394
|
policyName: string;
|
|
395
395
|
decision: "deny" | "instruct";
|
|
@@ -401,7 +401,7 @@ export interface ShadowVerdict {
|
|
|
401
401
|
|
|
402
402
|
export interface CombineOutcome {
|
|
403
403
|
final: FinalVerdict;
|
|
404
|
-
/** Reviewable regex policies Jev cleared (in
|
|
404
|
+
/** Reviewable regex policies Jev cleared (in observe mode: would have cleared). */
|
|
405
405
|
cleared: string[];
|
|
406
406
|
/**
|
|
407
407
|
* True when the first entry of `final` came from THIS TIER rather than from
|
|
@@ -414,11 +414,11 @@ export interface CombineOutcome {
|
|
|
414
414
|
decidedByJev: boolean;
|
|
415
415
|
activity: JevActivityFields;
|
|
416
416
|
/**
|
|
417
|
-
*
|
|
417
|
+
* Observe mode only, and only when Jev's own verdict was deny or instruct.
|
|
418
418
|
* Absent otherwise — including every enforce outcome, where the verdict was
|
|
419
419
|
* APPLIED and `decidedByJev` / the final entries already say so.
|
|
420
420
|
*/
|
|
421
|
-
|
|
421
|
+
observeVerdict?: ObserveVerdict;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
/**
|
|
@@ -495,7 +495,7 @@ export function combineTwoTier(
|
|
|
495
495
|
const notDenied = new Set(review.notDenied);
|
|
496
496
|
const cleared = wholePicture && !review.unclearableWarned ? verdicts.filter((v) => clears(v, asked, notDenied)).map((v) => v.policyName) : [];
|
|
497
497
|
|
|
498
|
-
// Built once, for both modes: enforce applies it,
|
|
498
|
+
// Built once, for both modes: enforce applies it, observe records it, and the
|
|
499
499
|
// two must never disagree about what the verdict WAS.
|
|
500
500
|
const jevEntry = { policyName: review.policyName, reason: review.reason ?? `Flagged by semantic review (${review.policyName})` };
|
|
501
501
|
const enforced = resolveEnforce(verdicts, cleared, review.decision, jevEntry);
|
|
@@ -510,7 +510,7 @@ export function combineTwoTier(
|
|
|
510
510
|
evaluator: review.requestCut ? "jev-fallback" : "jev",
|
|
511
511
|
...(review.requestCut ? { jevFallbackReason: JEV_REASON_REQUEST_CUT } : {}),
|
|
512
512
|
jevDecision: review.decision,
|
|
513
|
-
// Only a clear that SOFTENED the call (in
|
|
513
|
+
// Only a clear that SOFTENED the call (in observe mode: would have). One that
|
|
514
514
|
// Jev's own deny, or another regex deny, still decided over changed
|
|
515
515
|
// nothing — and `jev status` and the policy page's "Cleared by Jev" both
|
|
516
516
|
// read `jevCleared` as calls Jev let through.
|
|
@@ -520,11 +520,11 @@ export function combineTwoTier(
|
|
|
520
520
|
jevMode: mode,
|
|
521
521
|
};
|
|
522
522
|
|
|
523
|
-
if (mode === "
|
|
523
|
+
if (mode === "observe") {
|
|
524
524
|
// Jev's own deny or instruct, exactly as enforce mode would have applied it
|
|
525
525
|
// (upward only: a cut or injected call keeps it, as the enforce branch
|
|
526
526
|
// below does). A clear is recorded in `jevCleared`, not here.
|
|
527
|
-
const
|
|
527
|
+
const observeVerdict: ObserveVerdict | undefined =
|
|
528
528
|
review.decision === "deny" || review.decision === "instruct"
|
|
529
529
|
? {
|
|
530
530
|
policyName: jevEntry.policyName,
|
|
@@ -535,7 +535,7 @@ export function combineTwoTier(
|
|
|
535
535
|
version: review.model && JEV_MODEL_RE.test(review.model) ? review.model : "jev",
|
|
536
536
|
}
|
|
537
537
|
: undefined;
|
|
538
|
-
return { final: legacy, cleared, decidedByJev: false, activity, ...(
|
|
538
|
+
return { final: legacy, cleared, decidedByJev: false, activity, ...(observeVerdict ? { observeVerdict } : {}) };
|
|
539
539
|
}
|
|
540
540
|
|
|
541
541
|
return { ...enforced, cleared, activity };
|
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
* - A `deny` policy blocks only on strong evidence; moderate evidence warns.
|
|
14
14
|
* - The user may clear a policy only if (a) they explicitly asked for this
|
|
15
15
|
* action, (b) everything the call affects stays inside what they asked for
|
|
16
|
-
* (the `scope` probe), (c) when the call names identifiable targets,
|
|
17
|
-
* them appears in what they typed — checked here, in code, not by the
|
|
18
|
-
* — and (d) the request does not look like it is talking to the
|
|
16
|
+
* (the `scope` probe), (c) when the call names identifiable targets, EVERY
|
|
17
|
+
* one of them appears in what they typed — checked here, in code, not by the
|
|
18
|
+
* model — and (d) the request does not look like it is talking to the
|
|
19
|
+
* reviewer. When the local shell scan may have missed part of the command
|
|
20
|
+
* (`$'…'`, a heredoc, `$(…)`, …), (c) cannot be checked and nothing is
|
|
21
|
+
* cleared: the scan can see an innocent first target and stop before the
|
|
22
|
+
* destructive one.
|
|
19
23
|
* A call that names no target is never cleared by default: the scope answer
|
|
20
24
|
* has to carry it.
|
|
21
25
|
* - The injection probe withdraws any override, and turns a policy that has
|
|
@@ -68,44 +72,117 @@ function tokensOf(text: string): string[] {
|
|
|
68
72
|
.filter((t) => t.length >= 3 && !GENERIC_TOKENS.has(t) && !/^\d+$/.test(t));
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What a tool call acts on, as the local target check sees it.
|
|
77
|
+
*
|
|
78
|
+
* `groups` holds one entry per identifiable target — a non-flag argument of a
|
|
79
|
+
* shell command (path components and all), or, for any other tool, the whole
|
|
80
|
+
* of its argument values taken together — and each entry is the set of words
|
|
81
|
+
* that name it. `targets` is every word of every group.
|
|
82
|
+
*
|
|
83
|
+
* `complete` is false when the shell scan may have missed a word bash would
|
|
84
|
+
* run (`ScannedCommand.complete`). The groups are then a lower bound, and no
|
|
85
|
+
* clear may rest on them: see {@link everyTargetNamed}'s callers.
|
|
86
|
+
*/
|
|
87
|
+
export interface TargetScan {
|
|
88
|
+
targets: Set<string>;
|
|
89
|
+
groups: Set<string>[];
|
|
90
|
+
complete: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/**
|
|
72
94
|
* The words that identify WHAT a tool call acts on: its non-flag arguments
|
|
73
95
|
* (path components included), file paths, URL hosts, MCP argument values.
|
|
74
96
|
* The verb is left to Jev's `user_asked` question; this only checks the noun.
|
|
75
97
|
*/
|
|
76
|
-
export function
|
|
77
|
-
const
|
|
98
|
+
export function scanTargets(toolInput: Record<string, unknown>): TargetScan {
|
|
99
|
+
const groups: Set<string>[] = [];
|
|
100
|
+
const addGroup = (tok: string) => {
|
|
101
|
+
const words = tokensOf(tok);
|
|
102
|
+
if (words.length > 0) groups.push(new Set(words));
|
|
103
|
+
};
|
|
78
104
|
const command = typeof toolInput.command === "string" ? toolInput.command : null;
|
|
79
105
|
if (command) {
|
|
80
|
-
|
|
106
|
+
const scanned = scanCommand(command);
|
|
107
|
+
for (const seg of scanned.segments) {
|
|
81
108
|
for (const tok of seg.slice(1)) {
|
|
82
|
-
if (tok.startsWith("-"))
|
|
83
|
-
for (const t of tokensOf(tok)) out.add(t);
|
|
109
|
+
if (!tok.startsWith("-")) addGroup(tok);
|
|
84
110
|
}
|
|
85
111
|
}
|
|
86
112
|
// Empty reads as "names no target", and lets consent rest on Jev's answers
|
|
87
113
|
// alone. But the scanner is not bash — a `#` inside `$'…'`, `${x:- # }` or
|
|
88
114
|
// backticks ends its view early, and it reads only MAX_SCAN_CHARS — so an
|
|
89
115
|
// empty scan may just have stopped before the target. Then judge the words
|
|
90
|
-
// as written. Only ever stricter: an empty set already passed.
|
|
91
|
-
|
|
116
|
+
// as written. Only ever stricter: an empty set already passed. (Such a scan
|
|
117
|
+
// is also reported incomplete, which withholds the clear on its own; this
|
|
118
|
+
// keeps the recorded targets honest.)
|
|
119
|
+
if (groups.length === 0) {
|
|
92
120
|
for (const piece of command.split(/[;&|\n]+/)) {
|
|
93
121
|
for (const tok of piece.trim().split(/\s+/).slice(1)) {
|
|
94
|
-
if (!tok.startsWith("-"))
|
|
122
|
+
if (!tok.startsWith("-")) addGroup(tok);
|
|
95
123
|
}
|
|
96
124
|
}
|
|
97
125
|
}
|
|
98
|
-
return
|
|
126
|
+
return { targets: new Set(groups.flatMap((g) => [...g])), groups, complete: scanned.complete };
|
|
99
127
|
}
|
|
128
|
+
const all = new Set<string>();
|
|
100
129
|
for (const [key, value] of Object.entries(toolInput)) {
|
|
101
130
|
if (typeof value !== "string" || value.length > 300) continue;
|
|
102
131
|
if (/content|old_string|new_string|body|text|prompt/i.test(key)) continue;
|
|
103
|
-
for (const t of tokensOf(value))
|
|
132
|
+
for (const t of tokensOf(value)) all.add(t);
|
|
133
|
+
}
|
|
134
|
+
// A non-shell tool's fields qualify one another (`owner`, `repo`, `branch`)
|
|
135
|
+
// rather than naming separate targets, so they are ONE target: naming any of
|
|
136
|
+
// them names it.
|
|
137
|
+
return { targets: all, groups: all.size > 0 ? [all] : [], complete: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every word of {@link scanTargets}, flattened. */
|
|
141
|
+
export function targetTokens(toolInput: Record<string, unknown>): Set<string> {
|
|
142
|
+
return scanTargets(toolInput).targets;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* True when the user's own words name EVERY target the call acts on — each
|
|
147
|
+
* group through at least one of its words.
|
|
148
|
+
*
|
|
149
|
+
* Not "any one": `rm -rf build/ ~/important` after "clean the build" names
|
|
150
|
+
* `build` and not `important`, and a clear on the first would carry the
|
|
151
|
+
* second, which nobody asked for.
|
|
152
|
+
*/
|
|
153
|
+
export function everyTargetNamed(scan: TargetScan, userSaid: ReadonlyArray<string>): boolean {
|
|
154
|
+
if (userSaid.length === 0 || scan.groups.length === 0) return false;
|
|
155
|
+
const said = userSaid.join("\n").toLowerCase();
|
|
156
|
+
return scan.groups.every((g) => {
|
|
157
|
+
for (const t of g) if (said.includes(t)) return true;
|
|
158
|
+
return false;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True when the user's words name SOME of the call's targets but not all:
|
|
164
|
+
* they drew a line and the call reaches past it. Naming none is not this — a
|
|
165
|
+
* goal ("fix the failing tests") names no path at all.
|
|
166
|
+
*/
|
|
167
|
+
export function partlyNamed(scan: TargetScan, userSaid: ReadonlyArray<string>): boolean {
|
|
168
|
+
if (userSaid.length === 0 || scan.groups.length < 2) return false;
|
|
169
|
+
const said = userSaid.join("\n").toLowerCase();
|
|
170
|
+
let named = 0;
|
|
171
|
+
for (const g of scan.groups) {
|
|
172
|
+
for (const t of g) {
|
|
173
|
+
if (said.includes(t)) {
|
|
174
|
+
named++;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
104
178
|
}
|
|
105
|
-
return
|
|
179
|
+
return named > 0 && named < scan.groups.length;
|
|
106
180
|
}
|
|
107
181
|
|
|
108
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* True when the user's own words name at least one of `targets`. The deciders
|
|
184
|
+
* do not use this any-one check — see {@link everyTargetNamed}.
|
|
185
|
+
*/
|
|
109
186
|
export function targetNamedByUser(targets: Set<string>, userSaid: ReadonlyArray<string>): boolean {
|
|
110
187
|
if (userSaid.length === 0) return false;
|
|
111
188
|
// Nothing identifiable to check is not a match. This used to return true,
|
|
@@ -136,7 +213,7 @@ export function decide(
|
|
|
136
213
|
const injected = injection !== null && injection >= thresholds.injection;
|
|
137
214
|
const scope = typeof answers.scope === "number" ? answers.scope : null;
|
|
138
215
|
const withinScope = scope !== null && scope >= thresholds.scope;
|
|
139
|
-
let
|
|
216
|
+
let scan: TargetScan | null = null;
|
|
140
217
|
|
|
141
218
|
const outcomes: PolicyOutcome[] = selected.map((p) => {
|
|
142
219
|
const evidence = Math.min(...p.probes.map((probe) => answers[`${p.name}.${probe.id}`] ?? 0));
|
|
@@ -161,13 +238,18 @@ export function decide(
|
|
|
161
238
|
|
|
162
239
|
const fired: PolicyOutcome["verdict"] = p.mode === "deny" && evidence >= thresholds.deny ? "deny" : "instruct";
|
|
163
240
|
if (p.userCanOverride && userAsked !== null && userAsked >= thresholds.userAsked && withinScope) {
|
|
164
|
-
|
|
241
|
+
scan ??= scanTargets(toolInput);
|
|
242
|
+
// A shell scan that may have missed a word cannot say what the call
|
|
243
|
+
// touches, so it cannot say the user named it: no clear. Not rescued by
|
|
244
|
+
// `userSaidCut` — that gap is in what the human typed, this one is in
|
|
245
|
+
// the call.
|
|
246
|
+
if (!scan.complete) return { ...base, verdict: fired, targetScanIncomplete: true };
|
|
165
247
|
// No identifiable target: the scope answer (already required) carries it.
|
|
166
|
-
// Otherwise
|
|
248
|
+
// Otherwise EVERY target must also appear in the user's own words —
|
|
167
249
|
// unless the words we hold were cut, when "absent" is not something this
|
|
168
250
|
// check knows (see {@link DecideV1Options.userSaidCut}).
|
|
169
|
-
const named =
|
|
170
|
-
if (
|
|
251
|
+
const named = scan.groups.length > 0 && everyTargetNamed(scan, userSaid);
|
|
252
|
+
if (scan.groups.length === 0 || named || userSaidCut) return { ...base, targetNamedByUser: named, verdict: "overridden" };
|
|
171
253
|
}
|
|
172
254
|
return { ...base, verdict: fired };
|
|
173
255
|
});
|
|
@@ -293,12 +375,16 @@ export interface DecideV1Options {
|
|
|
293
375
|
* - A policy fires exactly as in v0 (every probe holds, no exemption).
|
|
294
376
|
* - Injection withdraws every clear and turns a fired policy into a block.
|
|
295
377
|
* - The human asked for THIS operation on THIS target (`op_requested`), the
|
|
296
|
-
* call reaches no further (`beyond_task`), and — when the call names
|
|
297
|
-
*
|
|
298
|
-
* proposal they replied to: the policy is cleared.
|
|
378
|
+
* call reaches no further (`beyond_task`), and — when the call names
|
|
379
|
+
* targets — every one of them appears in what the human typed or in the
|
|
380
|
+
* agent proposal they replied to: the policy is cleared.
|
|
381
|
+
* - A shell command the local scan could not read whole is cleared and
|
|
382
|
+
* softened by neither route: see `ScannedCommand.complete` in `facts.ts`.
|
|
299
383
|
* - Otherwise, the call is a step toward the human's task (`task_step`) and
|
|
300
384
|
* reaches no further: a warn-level outcome is cleared and a block is
|
|
301
|
-
* softened to a warning. A goal never licenses a block on its own.
|
|
385
|
+
* softened to a warning. A goal never licenses a block on its own. On a
|
|
386
|
+
* shell command whose targets the human named only in part, this route
|
|
387
|
+
* does not apply either.
|
|
302
388
|
* - Policies with `userCanOverride: false` are never cleared or softened.
|
|
303
389
|
* - Nothing fired, but the call reaches beyond the task, is not a step toward
|
|
304
390
|
* it, and some "does it do X" probe is at least half-raised: warn.
|
|
@@ -321,11 +407,13 @@ export function decideV1(
|
|
|
321
407
|
const task = num("task_step");
|
|
322
408
|
const op = num("op_requested");
|
|
323
409
|
const beyond = num("beyond_task");
|
|
324
|
-
let
|
|
410
|
+
let scan: TargetScan | null = null;
|
|
411
|
+
const targetScan = (): TargetScan => (scan ??= scanTargets(toolInput));
|
|
412
|
+
const evidenceSaid = agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
|
|
325
413
|
const targetOk = (): { ok: boolean; named: boolean } => {
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
const named =
|
|
414
|
+
const s = targetScan();
|
|
415
|
+
if (s.groups.length === 0) return { ok: true, named: false };
|
|
416
|
+
const named = everyTargetNamed(s, evidenceSaid);
|
|
329
417
|
return { ok: named || userSaidCut, named };
|
|
330
418
|
};
|
|
331
419
|
|
|
@@ -349,12 +437,29 @@ export function decideV1(
|
|
|
349
437
|
|
|
350
438
|
const fired: PolicyOutcome["verdict"] = p.mode === "deny" && evidence >= t.deny ? "deny" : "instruct";
|
|
351
439
|
if (!p.userCanOverride) return { ...base, verdict: fired };
|
|
440
|
+
// The shell scan may have missed a word bash would run (`$'…'`, a heredoc,
|
|
441
|
+
// `$(…)`, …): what the call touches is not known here, so neither intent
|
|
442
|
+
// route may clear or soften it. Jev's own deny or instruct stands.
|
|
443
|
+
if (!targetScan().complete) return { ...base, verdict: fired, targetScanIncomplete: true };
|
|
352
444
|
|
|
353
445
|
if (op !== null && op >= t.opRequested && beyond !== null && beyond < t.opBeyondMax) {
|
|
354
446
|
const target = targetOk();
|
|
355
447
|
if (target.ok) return { ...base, targetNamedByUser: target.named, verdict: "overridden", intent: "op-requested" };
|
|
356
448
|
}
|
|
357
|
-
|
|
449
|
+
// The task-step route reaches `combine.ts` as a clear too — a warning it
|
|
450
|
+
// leaves behind clears a reviewable regex deny. It is consent by GOAL, so
|
|
451
|
+
// a shell command whose targets the human never named still rides on it
|
|
452
|
+
// ("fix the failing tests" → `rm -rf node_modules`). But once the human
|
|
453
|
+
// HAS named targets, they drew the line: a call reaching past it is not
|
|
454
|
+
// softened. After "clean the build", `rm -rf build/ ~/important` is not.
|
|
455
|
+
if (
|
|
456
|
+
taskClears &&
|
|
457
|
+
task !== null &&
|
|
458
|
+
task >= t.taskStep &&
|
|
459
|
+
beyond !== null &&
|
|
460
|
+
beyond < t.taskBeyondMax &&
|
|
461
|
+
!(typeof toolInput.command === "string" && !userSaidCut && partlyNamed(targetScan(), evidenceSaid))
|
|
462
|
+
) {
|
|
358
463
|
if (fired === "instruct") return { ...base, verdict: "overridden", intent: "task-step" };
|
|
359
464
|
return { ...base, verdict: "instruct", intent: "downgraded-task-step" };
|
|
360
465
|
}
|
|
@@ -98,7 +98,7 @@ export interface SemanticOptions {
|
|
|
98
98
|
*/
|
|
99
99
|
signal?: AbortSignal;
|
|
100
100
|
model?: string;
|
|
101
|
-
/** Overrides the resolved set (
|
|
101
|
+
/** Overrides the resolved set (the installed packs' checks; empty with none). */
|
|
102
102
|
policies?: ReadonlyArray<SemanticPolicy>;
|
|
103
103
|
/** The agent the call is for: a pack scoped to other agents contributes no checks. */
|
|
104
104
|
cli?: string;
|
|
@@ -306,8 +306,8 @@ export function prepareSemantic(input: SemanticInput, opts: SemanticOptions = {}
|
|
|
306
306
|
scanned,
|
|
307
307
|
input.projectRoot ?? null,
|
|
308
308
|
);
|
|
309
|
-
// The
|
|
310
|
-
//
|
|
309
|
+
// The checks the installed packs declare — none when no pack declares any,
|
|
310
|
+
// and then nothing is asked (see `pack-policies.ts`). Resolved
|
|
311
311
|
// here rather than by the caller because this is the one place the policy set
|
|
312
312
|
// is read, and a second resolution site is a second answer to "what does this
|
|
313
313
|
// machine ask Jev". A caller that supplies `policies` (a replay, an ablation,
|
|
@@ -496,7 +496,7 @@ export interface VerdictLogMeta {
|
|
|
496
496
|
eventType: string;
|
|
497
497
|
/**
|
|
498
498
|
* What the handler did with the outcome: combined it with the regex results
|
|
499
|
-
* (`two-tier`), logged it while enforcing the regex result (`
|
|
499
|
+
* (`two-tier`), logged it while enforcing the regex result (`observe`), or
|
|
500
500
|
* kept the regex result because Jev never answered (`legacy-fallback`).
|
|
501
501
|
*
|
|
502
502
|
* A TRUNCATED call is `two-tier`, not `legacy-fallback`: its clears were
|
|
@@ -504,7 +504,7 @@ export interface VerdictLogMeta {
|
|
|
504
504
|
* `combine.ts`). `truncated` on the same row is what says the clearing half
|
|
505
505
|
* was off for it; the activity row records `jev-fallback` / `truncated`.
|
|
506
506
|
*/
|
|
507
|
-
applied: "two-tier" | "
|
|
507
|
+
applied: "two-tier" | "observe" | "legacy-fallback";
|
|
508
508
|
}
|
|
509
509
|
|
|
510
510
|
export function verdictLogRow(input: SemanticInput, outcome: SemanticOutcome, meta: VerdictLogMeta): Record<string, unknown> {
|
|
@@ -49,6 +49,39 @@ export interface ScannedCommand {
|
|
|
49
49
|
commentsRemoved: boolean;
|
|
50
50
|
/** The removed comment text, so the injection probe can still see it. */
|
|
51
51
|
comments: string[];
|
|
52
|
+
/**
|
|
53
|
+
* False when the scan may not have seen every word bash would run: the
|
|
54
|
+
* command was longer than `MAX_SCAN_CHARS`, or it uses syntax this scanner
|
|
55
|
+
* does not model (see {@link scanCommand}). Then `segments` is a GUESS — a
|
|
56
|
+
* `#` it read as a comment may not be one, and a word it never reached may be
|
|
57
|
+
* a target — and a caller must not rest a clear on what the segments omit.
|
|
58
|
+
*/
|
|
59
|
+
complete: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shells whose `-c` argument is a second command line this scan does not parse. */
|
|
63
|
+
const NESTED_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A `$` followed by this starts an expansion bash performs outside single
|
|
67
|
+
* quotes: `$NAME`, `$1`, `$@ $* $# $? $$ $! $-`, `${…}`, `$(…)` and `$((…))`.
|
|
68
|
+
* The scan does not resolve any of them — `DANGER=/critical; rm -rf $DANGER`
|
|
69
|
+
* names `/critical` only through the variable — so each makes it incomplete.
|
|
70
|
+
* A `$` before anything else (a space, `/`, the end) is a literal dollar.
|
|
71
|
+
*/
|
|
72
|
+
const EXPANSION_START = /[A-Za-z_0-9@*#?$!\-({]/;
|
|
73
|
+
|
|
74
|
+
/** A segment that hands a string to a shell to parse again: `eval …`, `bash -c …`, `sudo sh -lc …`. */
|
|
75
|
+
function runsNestedShell(tokens: string[]): boolean {
|
|
76
|
+
// One pass, so a segment of ten thousand `sh` words stays linear.
|
|
77
|
+
let shellSeen = false;
|
|
78
|
+
for (const tok of tokens) {
|
|
79
|
+
if (shellSeen && /^-[a-z]*c[a-z]*$/i.test(tok)) return true;
|
|
80
|
+
const base = tok.slice(tok.lastIndexOf("/") + 1);
|
|
81
|
+
if (base === "eval") return true;
|
|
82
|
+
if (NESTED_SHELLS.has(base)) shellSeen = true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
/**
|
|
@@ -56,6 +89,17 @@ export interface ScannedCommand {
|
|
|
56
89
|
* operator splitting and comment removal. It is deliberately not a full shell
|
|
57
90
|
* parser — it only needs to find words, segment boundaries and comments, and
|
|
58
91
|
* it must stay linear no matter what the agent sends.
|
|
92
|
+
*
|
|
93
|
+
* Because it is not bash, it says when it may be wrong: `complete` is false on
|
|
94
|
+
* any construct whose quoting or word boundaries it does not follow — `$'…'`
|
|
95
|
+
* and `$"…"`, any parameter expansion (`$NAME`, `$1`, `$@`, `${…}`, `$((…))`),
|
|
96
|
+
* `$(…)` and backticks, unquoted brace expansion (`{a,b}`, `{1..3}`) and
|
|
97
|
+
* globs (`*`, `?`, `[…]`), `<(…)` / `>(…)`, heredocs and
|
|
98
|
+
* here-strings (`<<`), a backslash-newline, a quote left open at the end,
|
|
99
|
+
* `eval` or a shell's `-c` string, and a command cut at `MAX_SCAN_CHARS`.
|
|
100
|
+
* Each of those can put a `#` where the scan sees a comment and bash does not
|
|
101
|
+
* (`echo $'x\' # '; rm -rf /critical` scans as `echo x`), or put a command
|
|
102
|
+
* where the scan sees one word. The flag costs one comparison per character.
|
|
59
103
|
*/
|
|
60
104
|
export function scanCommand(command: string): ScannedCommand {
|
|
61
105
|
const text = command.length > MAX_SCAN_CHARS ? command.slice(0, MAX_SCAN_CHARS) : command;
|
|
@@ -67,15 +111,27 @@ export function scanCommand(command: string): ScannedCommand {
|
|
|
67
111
|
let out = "";
|
|
68
112
|
let commentsRemoved = false;
|
|
69
113
|
const comments: string[] = [];
|
|
114
|
+
let complete = command.length <= MAX_SCAN_CHARS;
|
|
115
|
+
// Per-word, unquoted: an open `{` not yet closed, whether a `,` or `..`
|
|
116
|
+
// followed it, and an open `[`. Enough to spot brace expansion and globs.
|
|
117
|
+
let braceDepth = 0;
|
|
118
|
+
let braceSep = false;
|
|
119
|
+
let bracketOpen = false;
|
|
70
120
|
|
|
71
121
|
const endWord = () => {
|
|
72
122
|
if (inWord) tokens.push(word);
|
|
73
123
|
word = "";
|
|
74
124
|
inWord = false;
|
|
125
|
+
braceDepth = 0;
|
|
126
|
+
braceSep = false;
|
|
127
|
+
bracketOpen = false;
|
|
75
128
|
};
|
|
76
129
|
const endSegment = () => {
|
|
77
130
|
endWord();
|
|
78
|
-
if (tokens.length > 0)
|
|
131
|
+
if (tokens.length > 0) {
|
|
132
|
+
if (complete && runsNestedShell(tokens)) complete = false;
|
|
133
|
+
segments.push(tokens);
|
|
134
|
+
}
|
|
79
135
|
tokens = [];
|
|
80
136
|
};
|
|
81
137
|
|
|
@@ -83,6 +139,11 @@ export function scanCommand(command: string): ScannedCommand {
|
|
|
83
139
|
const c = text[i];
|
|
84
140
|
if (quote) {
|
|
85
141
|
out += c;
|
|
142
|
+
// Inside "…", bash still expands `$NAME`, `$(…)`, `${…}` and backticks,
|
|
143
|
+
// and a backslash-newline is a continuation: none of that is followed here.
|
|
144
|
+
if (quote === '"' && (c === "`" || (c === "$" && EXPANSION_START.test(text[i + 1] ?? "")) || (c === "\\" && text[i + 1] === "\n"))) {
|
|
145
|
+
complete = false;
|
|
146
|
+
}
|
|
86
147
|
if (c === quote) {
|
|
87
148
|
quote = null;
|
|
88
149
|
} else if (c === "\\" && quote === '"' && i + 1 < text.length) {
|
|
@@ -93,6 +154,16 @@ export function scanCommand(command: string): ScannedCommand {
|
|
|
93
154
|
}
|
|
94
155
|
continue;
|
|
95
156
|
}
|
|
157
|
+
// Unquoted constructs whose contents the scan does not parse as bash does.
|
|
158
|
+
if (
|
|
159
|
+
c === "`" ||
|
|
160
|
+
(c === "$" && (text[i + 1] === "'" || text[i + 1] === '"' || EXPANSION_START.test(text[i + 1] ?? ""))) ||
|
|
161
|
+
((c === "<" || c === ">") && text[i + 1] === "(") ||
|
|
162
|
+
(c === "<" && text[i + 1] === "<") ||
|
|
163
|
+
(c === "\\" && text[i + 1] === "\n")
|
|
164
|
+
) {
|
|
165
|
+
complete = false;
|
|
166
|
+
}
|
|
96
167
|
if (c === "'" || c === '"') {
|
|
97
168
|
quote = c;
|
|
98
169
|
inWord = true;
|
|
@@ -131,12 +202,30 @@ export function scanCommand(command: string): ScannedCommand {
|
|
|
131
202
|
out += c;
|
|
132
203
|
continue;
|
|
133
204
|
}
|
|
205
|
+
// Brace expansion and globs, unquoted: bash turns `{build,/critical}` and
|
|
206
|
+
// `/crit*` into words this scan never sees. A `{` or `}` word on its own
|
|
207
|
+
// (`{ ls; }`, `-exec rm {} \;`) and `{}` are not expansions.
|
|
208
|
+
if (c === "{") {
|
|
209
|
+
braceDepth++;
|
|
210
|
+
} else if (braceDepth > 0 && (c === "," || (c === "." && text[i + 1] === "."))) {
|
|
211
|
+
braceSep = true;
|
|
212
|
+
} else if (c === "}" && braceDepth > 0) {
|
|
213
|
+
braceDepth--;
|
|
214
|
+
if (braceSep) complete = false;
|
|
215
|
+
} else if (c === "*" || c === "?") {
|
|
216
|
+
complete = false;
|
|
217
|
+
} else if (c === "[") {
|
|
218
|
+
bracketOpen = true;
|
|
219
|
+
} else if (c === "]" && bracketOpen) {
|
|
220
|
+
complete = false;
|
|
221
|
+
}
|
|
134
222
|
word += c;
|
|
135
223
|
inWord = true;
|
|
136
224
|
out += c;
|
|
137
225
|
}
|
|
138
226
|
endSegment();
|
|
139
|
-
|
|
227
|
+
if (quote) complete = false;
|
|
228
|
+
return { segments, withoutComments: out.trimEnd(), commentsRemoved, comments, complete };
|
|
140
229
|
}
|
|
141
230
|
|
|
142
231
|
const PATH_LIKE = /^(?:\/|~|\.\.?(?:\/|$)|[^:]*\/)/;
|
|
@@ -469,7 +469,7 @@ async function postJson(url: string, bearer: string, body: unknown, signal: Abor
|
|
|
469
469
|
body: JSON.stringify(body),
|
|
470
470
|
signal,
|
|
471
471
|
// Never followed. The configured URL is the one `validateBaseUrl` checked
|
|
472
|
-
// (https, or loopback http in
|
|
472
|
+
// (https, or loopback http in observe mode only); a redirect would hand the
|
|
473
473
|
// answer — the thing that can clear a deny — to an origin nobody checked,
|
|
474
474
|
// plain http included. No provider redirects this POST.
|
|
475
475
|
redirect: "manual",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
* The one provider whose key is NOT in this file. `failproofai config --token`
|
|
56
56
|
* with a key carrying `jev:evaluate` stores that key in the `jev` slot of
|
|
57
57
|
* `credentials.json` and, when there is no `jev.json` yet, writes one naming
|
|
58
|
-
* this provider, the Cloud origin + `/enforcement/v1/jev` and `mode: "
|
|
58
|
+
* this provider, the Cloud origin + `/enforcement/v1/jev` and `mode: "observe"`.
|
|
59
59
|
* So for this provider:
|
|
60
60
|
*
|
|
61
61
|
* - the key comes from `credentials.json` (`readJevCloudCredential`), read with
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
*
|
|
81
81
|
* # `mode: "off"`
|
|
82
82
|
*
|
|
83
|
-
* Every provider accepts `off |
|
|
83
|
+
* Every provider accepts `off | observe | enforce`. `off` keeps the file — the
|
|
84
84
|
* endpoint, and for BYOK the key — while Jev does not run at all:
|
|
85
85
|
* `loadJevConfig` returns null exactly as for an absent file. It exists so the
|
|
86
86
|
* dashboard can switch the Cloud route off without deleting the file that
|
|
@@ -96,8 +96,16 @@ export type { JevCloudCredential } from "../fp-config";
|
|
|
96
96
|
|
|
97
97
|
export type JevProviderKind = "typesafe" | "openrouter" | "vercel" | "cloudflare" | "custom" | "failproofai";
|
|
98
98
|
|
|
99
|
-
/** `off` does not run Jev at all; `
|
|
100
|
-
export type JevConfigMode = "off" | "
|
|
99
|
+
/** `off` does not run Jev at all; `observe` logs Jev and enforces regex; `enforce` applies the combine rules. */
|
|
100
|
+
export type JevConfigMode = "off" | "observe" | "enforce";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A mode as read from a file, a flag or a request: `off`, `observe` or
|
|
104
|
+
* `enforce`. Null for anything else.
|
|
105
|
+
*/
|
|
106
|
+
export function parseJevMode(raw: unknown): JevConfigMode | null {
|
|
107
|
+
return raw === "off" || raw === "observe" || raw === "enforce" ? raw : null;
|
|
108
|
+
}
|
|
101
109
|
|
|
102
110
|
export interface JevConfig {
|
|
103
111
|
provider: JevProviderKind;
|
|
@@ -130,7 +138,7 @@ export interface JevConfig {
|
|
|
130
138
|
credentialOrigin?: string;
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
export const DEFAULT_JEV_MODE: "
|
|
141
|
+
export const DEFAULT_JEV_MODE: "observe" | "enforce" = "enforce";
|
|
134
142
|
|
|
135
143
|
export const JEV_PROVIDER_KINDS: readonly JevProviderKind[] = ["typesafe", "openrouter", "vercel", "cloudflare", "custom", "failproofai"];
|
|
136
144
|
|
|
@@ -311,7 +319,7 @@ function credentialQueryProblem(url: URL): string | null {
|
|
|
311
319
|
* No credentials in the URL — not as userinfo, not as a query parameter — and no
|
|
312
320
|
* fragment, because a key belongs in the key field, where it is sent as a bearer
|
|
313
321
|
* and never printed. `validateJevConfig` further accepts the loopback http form
|
|
314
|
-
* only in
|
|
322
|
+
* only in observe mode.
|
|
315
323
|
*/
|
|
316
324
|
export function validateBaseUrl(raw: unknown): ValidationResult<string> {
|
|
317
325
|
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, problem: "baseUrl must be a non-empty string" };
|
|
@@ -667,25 +675,26 @@ export function validateJevConfig(
|
|
|
667
675
|
}
|
|
668
676
|
|
|
669
677
|
if (o.mode !== undefined) {
|
|
670
|
-
|
|
671
|
-
|
|
678
|
+
const mode = parseJevMode(o.mode);
|
|
679
|
+
if (mode === null) return { ok: false, problem: 'mode must be "off", "observe" or "enforce"' };
|
|
680
|
+
cfg.mode = mode;
|
|
672
681
|
}
|
|
673
682
|
|
|
674
683
|
// Plain http reaches only a loopback host (`validateBaseUrl`), and nothing
|
|
675
684
|
// authenticates the server there: while the local proxy is down, any process
|
|
676
685
|
// of this user — the agent being judged included — can bind its port and
|
|
677
686
|
// answer "none" to every question. In enforce mode that answer clears
|
|
678
|
-
// reviewable denies; in
|
|
687
|
+
// reviewable denies; in observe mode it changes nothing, and `off` sends
|
|
679
688
|
// nothing at all, so enforce is the one mode it is refused in.
|
|
680
689
|
if (cfg.baseUrl !== undefined && isPlainHttp(cfg.baseUrl) && cfg.mode === "enforce") {
|
|
681
690
|
return {
|
|
682
691
|
ok: false,
|
|
683
692
|
problem:
|
|
684
|
-
"plain http (to localhost) is accepted only with mode
|
|
693
|
+
"plain http (to localhost) is accepted only with mode observe: in enforce mode Jev's answers can clear a deny, " +
|
|
685
694
|
"and while the local proxy is down any process on this machine could take its port and answer. " +
|
|
686
695
|
(cfg.provider === JEV_CLOUD_PROVIDER
|
|
687
|
-
? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode
|
|
688
|
-
: "Use https, or mode
|
|
696
|
+
? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode observe"
|
|
697
|
+
: "Use https, or mode observe"),
|
|
689
698
|
};
|
|
690
699
|
}
|
|
691
700
|
|