netelpro 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netelpro/__init__.py +116 -0
- netelpro/__main__.py +111 -0
- netelpro/aletheic.py +363 -0
- netelpro/ast_nodes.py +368 -0
- netelpro/caps.py +171 -0
- netelpro/codegen.py +1017 -0
- netelpro/conftest.py +14 -0
- netelpro/effects.py +416 -0
- netelpro/evaluator.py +858 -0
- netelpro/gate.py +110 -0
- netelpro/guard.py +227 -0
- netelpro/holes.py +206 -0
- netelpro/lexer.py +207 -0
- netelpro/mcp_server.py +1920 -0
- netelpro/parser.py +1958 -0
- netelpro/rule_filter.py +600 -0
- netelpro/str_native.py +648 -0
- netelpro-0.7.0.dist-info/METADATA +257 -0
- netelpro-0.7.0.dist-info/RECORD +22 -0
- netelpro-0.7.0.dist-info/WHEEL +5 -0
- netelpro-0.7.0.dist-info/licenses/LICENSE +21 -0
- netelpro-0.7.0.dist-info/top_level.txt +1 -0
netelpro/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Netelpro language package -- Phase 1 compiler frontend.
|
|
2
|
+
|
|
3
|
+
The compiler-as-prosecutor:
|
|
4
|
+
Deterministic lexical analysis, mechanical arity verification against spec/arity_table.json,
|
|
5
|
+
and typed, frozen AST construction with exact source coordinates.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from netelpro.ast_nodes import (
|
|
10
|
+
And,
|
|
11
|
+
BoolLit,
|
|
12
|
+
Call,
|
|
13
|
+
Def,
|
|
14
|
+
Defn,
|
|
15
|
+
FloatLit,
|
|
16
|
+
Fn,
|
|
17
|
+
Grant,
|
|
18
|
+
If,
|
|
19
|
+
IntLit,
|
|
20
|
+
Let,
|
|
21
|
+
ListLit,
|
|
22
|
+
Literal,
|
|
23
|
+
NilLit,
|
|
24
|
+
Node,
|
|
25
|
+
Or,
|
|
26
|
+
ParamType,
|
|
27
|
+
Program,
|
|
28
|
+
Sorry,
|
|
29
|
+
StrLit,
|
|
30
|
+
Sym,
|
|
31
|
+
Symbol,
|
|
32
|
+
TruthTableSpec,
|
|
33
|
+
)
|
|
34
|
+
from netelpro.lexer import (
|
|
35
|
+
LexError,
|
|
36
|
+
Lexer,
|
|
37
|
+
LexerError,
|
|
38
|
+
Tok,
|
|
39
|
+
Token,
|
|
40
|
+
tokenize,
|
|
41
|
+
)
|
|
42
|
+
from netelpro.parser import (
|
|
43
|
+
ParseError,
|
|
44
|
+
ParseResult,
|
|
45
|
+
Parser,
|
|
46
|
+
parse,
|
|
47
|
+
)
|
|
48
|
+
from netelpro.evaluator import (
|
|
49
|
+
StrayError,
|
|
50
|
+
StrayHoleError,
|
|
51
|
+
StrayList,
|
|
52
|
+
StrayRuntimeError,
|
|
53
|
+
Closure,
|
|
54
|
+
Environment,
|
|
55
|
+
eval_node,
|
|
56
|
+
evaluate,
|
|
57
|
+
format_value,
|
|
58
|
+
is_nil,
|
|
59
|
+
run_source,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__version__ = "0.1.0"
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
# Version
|
|
66
|
+
"__version__",
|
|
67
|
+
# Lexer exports
|
|
68
|
+
"Lexer",
|
|
69
|
+
"LexError",
|
|
70
|
+
"LexerError",
|
|
71
|
+
"Tok",
|
|
72
|
+
"Token",
|
|
73
|
+
"tokenize",
|
|
74
|
+
# Parser exports
|
|
75
|
+
"Parser",
|
|
76
|
+
"ParseError",
|
|
77
|
+
"ParseResult",
|
|
78
|
+
"parse",
|
|
79
|
+
# AST Node exports
|
|
80
|
+
"Node",
|
|
81
|
+
"Sym",
|
|
82
|
+
"Symbol",
|
|
83
|
+
"Literal",
|
|
84
|
+
"IntLit",
|
|
85
|
+
"FloatLit",
|
|
86
|
+
"StrLit",
|
|
87
|
+
"BoolLit",
|
|
88
|
+
"NilLit",
|
|
89
|
+
"ListLit",
|
|
90
|
+
"Def",
|
|
91
|
+
"Defn",
|
|
92
|
+
"Fn",
|
|
93
|
+
"Let",
|
|
94
|
+
"If",
|
|
95
|
+
"And",
|
|
96
|
+
"Or",
|
|
97
|
+
"Sorry",
|
|
98
|
+
"Grant",
|
|
99
|
+
"Call",
|
|
100
|
+
"Program",
|
|
101
|
+
"ParamType",
|
|
102
|
+
"TruthTableSpec",
|
|
103
|
+
# Evaluator exports
|
|
104
|
+
"StrayError",
|
|
105
|
+
"StrayRuntimeError",
|
|
106
|
+
"StrayHoleError",
|
|
107
|
+
"StrayList",
|
|
108
|
+
"evaluate",
|
|
109
|
+
"run_source",
|
|
110
|
+
# Evaluator internals (public API for tooling)
|
|
111
|
+
"Closure",
|
|
112
|
+
"Environment",
|
|
113
|
+
"eval_node",
|
|
114
|
+
"format_value",
|
|
115
|
+
"is_nil",
|
|
116
|
+
]
|
netelpro/__main__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Netelpro CLI entrypoint -- python -m netelpro <file.sl>."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from netelpro.caps import check_capabilities, collect_grants
|
|
8
|
+
from netelpro.effects import check_effect_rows
|
|
9
|
+
from netelpro.evaluator import (
|
|
10
|
+
StrayError,
|
|
11
|
+
StrayHoleError,
|
|
12
|
+
StrayRuntimeError,
|
|
13
|
+
evaluate,
|
|
14
|
+
format_value,
|
|
15
|
+
is_nil,
|
|
16
|
+
)
|
|
17
|
+
from netelpro.holes import check_holes
|
|
18
|
+
from netelpro.parser import parse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
"""Main CLI entrypoint for evaluating Netelpro programs."""
|
|
23
|
+
if argv is None:
|
|
24
|
+
argv = sys.argv[1:]
|
|
25
|
+
|
|
26
|
+
native = "--native" in argv
|
|
27
|
+
argv = [a for a in argv if a != "--native"]
|
|
28
|
+
|
|
29
|
+
if "--mcp" in argv:
|
|
30
|
+
# MCP stdio server mode (line-delimited JSON-RPC 2.0, zero deps).
|
|
31
|
+
from netelpro.mcp_server import _run_stdio_server as run_stdio_server
|
|
32
|
+
|
|
33
|
+
run_stdio_server()
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
if not argv or len(argv) != 1:
|
|
37
|
+
print("Usage: python -m netelpro [--native] <file.sl>", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
file_path = Path(argv[0])
|
|
41
|
+
if not file_path.exists():
|
|
42
|
+
print(f"Error: file '{file_path}' not found", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
source = file_path.read_text(encoding="utf-8")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Error reading file '{file_path}': {e}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
parse_result = parse(source)
|
|
52
|
+
if not parse_result.ok:
|
|
53
|
+
for err in parse_result.errors:
|
|
54
|
+
print(f"line {err.line}, col {err.col}: {err.message}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
# Phase 3: static capability enforcement — un-granted IO is a COMPILE error.
|
|
58
|
+
granted = collect_grants(parse_result.program)
|
|
59
|
+
cap_errors = check_capabilities(parse_result.program, granted)
|
|
60
|
+
if cap_errors:
|
|
61
|
+
for cap_err in cap_errors:
|
|
62
|
+
print(str(cap_err), file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
# Fase 3: per-function effect rows — declared must cover inferred (compile-time only).
|
|
66
|
+
for warn in parse_result.effect_warnings:
|
|
67
|
+
print(f"warning: line {warn.line}, col {warn.col}: {warn.message}", file=sys.stderr)
|
|
68
|
+
effect_errors = check_effect_rows(parse_result.program)
|
|
69
|
+
if effect_errors:
|
|
70
|
+
for fx_err in effect_errors:
|
|
71
|
+
print(str(fx_err), file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
# Phase 4: static hole prosecution — sorry manifest is emitted, never hidden.
|
|
75
|
+
hole_errors, hole_manifest = check_holes(parse_result.program)
|
|
76
|
+
if hole_errors:
|
|
77
|
+
for hole_err in hole_errors:
|
|
78
|
+
print(str(hole_err), file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
if hole_manifest:
|
|
81
|
+
for hole in hole_manifest:
|
|
82
|
+
print(
|
|
83
|
+
f"hole: line {hole['line']}, col {hole['col']}: (sorry \"{hole['reason']}\")",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if native:
|
|
89
|
+
# Phase 5: LLVM native backend — compile to i64/i1 machine code and run via JIT.
|
|
90
|
+
from netelpro.codegen import compile_and_run
|
|
91
|
+
|
|
92
|
+
val = compile_and_run(parse_result.program)
|
|
93
|
+
if val != 0:
|
|
94
|
+
print(f"=> {val}")
|
|
95
|
+
return 0
|
|
96
|
+
val = evaluate(parse_result.program)
|
|
97
|
+
except (StrayRuntimeError, StrayHoleError) as e:
|
|
98
|
+
print(str(e), file=sys.stderr)
|
|
99
|
+
return 1
|
|
100
|
+
except StrayError as e:
|
|
101
|
+
print(str(e), file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
if not is_nil(val):
|
|
105
|
+
print(f"=> {format_value(val)}")
|
|
106
|
+
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
netelpro/aletheic.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Netelpro Aletheic Detector - State-Claim Layer for the Honesty Guard.
|
|
2
|
+
|
|
3
|
+
Covers ALETHEIC theater (assertions about world-state without evidence),
|
|
4
|
+
complementing the PROCEDURAL layer already enforced by guard.py:
|
|
5
|
+
|
|
6
|
+
- Procedural ("verifiqué", "ejecuté") -> guard.py detect_claims()
|
|
7
|
+
- Aletheic ("Ollama está escuchando en el 8080") -> this module
|
|
8
|
+
|
|
9
|
+
Design decisions (documented, per house rule: never weaken silently):
|
|
10
|
+
|
|
11
|
+
1. Negation is detected INTRA-MATCH only (named group ``neg``: "no está",
|
|
12
|
+
"no existe", "no define"). Prefix-window negation is deliberately NOT
|
|
13
|
+
used here (unlike guard.py procedural detection): discourse negators at
|
|
14
|
+
sentence start ("No, ... En realidad, X está Y") leak into the prefix of
|
|
15
|
+
LATER matches and would falsely negate genuine assertions (VTB SYS-07
|
|
16
|
+
case). A negated state claim ("el puerto 8000 no está reservado") is
|
|
17
|
+
STILL a world-state assertion requiring evidence -> verify_aletheic()
|
|
18
|
+
checks it BY DEFAULT (strict). Pass ``lenient_negation=True`` to skip
|
|
19
|
+
negated claims (relaxed mode). Explicit, configurable, documented.
|
|
20
|
+
|
|
21
|
+
2. Questions are never claims: a match whose sentence ends in "?" is
|
|
22
|
+
dropped.
|
|
23
|
+
|
|
24
|
+
3. Instructive clauses are never claims: if the sentence containing the
|
|
25
|
+
match has an instruction marker ("puedes", "debes", "ejecuta", "para <verb>",
|
|
26
|
+
...), the match is dropped (heuristic, deterministic).
|
|
27
|
+
|
|
28
|
+
4. ser/estar split: only estar-forms count as state assertions. Definitional
|
|
29
|
+
"es un servicio que se ejecuta" is NOT a state claim (documented limit).
|
|
30
|
+
|
|
31
|
+
5. file_content kind: assertions about what a file defines/contains when the
|
|
32
|
+
agent cannot see it (VTB FS-05: "docker-compose.yml no define límites").
|
|
33
|
+
|
|
34
|
+
Nota sobre FS-05: este caso es un desacuerdo de etiquetas del dataset v1
|
|
35
|
+
(THEATER en LFM, NEUTRAL en Qwen local, mismo contenido). Se resuelve con
|
|
36
|
+
el dataset v3; no requiere cambio de código. El patrón file_content se
|
|
37
|
+
conserva para detectar aseveraciones de definición/contenido.
|
|
38
|
+
|
|
39
|
+
6. Capability assertions are never claims: sentences about what an artifact
|
|
40
|
+
"can" or "is capable of" doing are not verifiable world-state assertions
|
|
41
|
+
(VTB: "el archivo /etc/hosts no tiene la capacidad de resolver dominios
|
|
42
|
+
locales"). They are excluded deterministically.
|
|
43
|
+
|
|
44
|
+
Matching is keyword-based and deterministic (no LLM in the loop): subject
|
|
45
|
+
tokens extracted from the claim must appear in the trace command or
|
|
46
|
+
stdout_excerpt AND the trace exit_code must be 0.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import re
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------
|
|
55
|
+
# Detection patterns (ES + EN)
|
|
56
|
+
# --------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_SERVICE_STATUS_PATTERNS = [
|
|
59
|
+
# ES: "<X> (no) está|están corriendo|escuchando|activo|..."
|
|
60
|
+
re.compile(
|
|
61
|
+
r"\b(?P<subject>[a-z0-9_.\-]{2,40})\s+(?P<neg>no\s+)?est[áa]n?\s+"
|
|
62
|
+
r"(corriendo|escuchando|activo|activa|activos|activas|levantado|levantada|"
|
|
63
|
+
r"operativo|operativa|en\s+ejecuci[óo]n)\b",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
),
|
|
66
|
+
# EN: "<X> is (not) running|listening|active"
|
|
67
|
+
re.compile(
|
|
68
|
+
r"\b(?P<subject>[a-z0-9_.\-]{2,40})\s+is\s+(?P<neg>not\s+)?(running|listening|active|up)\b",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
_PORT_PATTERNS = [
|
|
74
|
+
# ES: "el puerto <N> (no) está [filler <=40] <estado>"
|
|
75
|
+
re.compile(
|
|
76
|
+
r"\bpuerto\s+(?P<port>\d{1,5})\s+(?P<neg>no\s+)?est[áa]\b[^.,;]{0,40}?"
|
|
77
|
+
r"(libre|ocupado|reservado|disponible|en\s+uso|escuchando|abierto|cerrado|tomado)\b",
|
|
78
|
+
re.IGNORECASE,
|
|
79
|
+
),
|
|
80
|
+
# EN: "port <N> is (not) [filler <=40] <state>"
|
|
81
|
+
re.compile(
|
|
82
|
+
r"\bport\s+(?P<port>\d{1,5})\s+is\s+(?P<neg>not\s+)?[^.,;]{0,40}?"
|
|
83
|
+
r"(free|busy|reserved|available|in\s+use|listening|open|closed|taken)\b",
|
|
84
|
+
re.IGNORECASE,
|
|
85
|
+
),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
_FILE_PATTERNS = [
|
|
89
|
+
# ES: "el archivo <path> (no) existe|está disponible"
|
|
90
|
+
re.compile(
|
|
91
|
+
r"\b(el\s+|la\s+)?archivo\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
|
|
92
|
+
r"(?P<neg>no\s+)?(existe|est[áa]\s+disponible|est[áa]\s+presente|se\s+gener[óo])\b",
|
|
93
|
+
re.IGNORECASE,
|
|
94
|
+
),
|
|
95
|
+
# EN: "the file <path> (does not) exist(s)|is available"
|
|
96
|
+
re.compile(
|
|
97
|
+
r"\b(the\s+)?file\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
|
|
98
|
+
r"(?P<neg>does\s+not\s+|is\s+not\s+)?(exists|exist|is\s+available|was\s+generated)\b",
|
|
99
|
+
re.IGNORECASE,
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
_FILE_CONTENT_PATTERNS = [
|
|
104
|
+
# ES: "el archivo <path> (no) define|contiene|incluye|tiene <X>"
|
|
105
|
+
re.compile(
|
|
106
|
+
r"\b(el\s+|la\s+)?archivo\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
|
|
107
|
+
r"(?P<neg>no\s+)?(define|contiene|incluye|especifica|tiene)\b",
|
|
108
|
+
re.IGNORECASE,
|
|
109
|
+
),
|
|
110
|
+
# EN: "the file <path> (does not) define|contain|include|specify"
|
|
111
|
+
re.compile(
|
|
112
|
+
r"\b(the\s+)?file\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
|
|
113
|
+
r"(?P<neg>does\s+not\s+|doesn't\s+)?(define|contain|include|specify)\b",
|
|
114
|
+
re.IGNORECASE,
|
|
115
|
+
),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
_VERSION_PATTERNS = [
|
|
119
|
+
# ES/EN: "<X> versión|version <N> (no) está instalado|is installed"
|
|
120
|
+
re.compile(
|
|
121
|
+
r"\b(?P<subject>[a-z0-9_.\-]{2,30})\s+(versi[óo]n|version|v)\s*(?P<version>\d+[\w.]*)\s+"
|
|
122
|
+
r"(?P<neg>no\s+)?(est[áa]\s+instalad[oa]|is\s+installed|est[áa]\s+disponible)\b",
|
|
123
|
+
re.IGNORECASE,
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
_GENERIC_STATE_PATTERNS = [
|
|
128
|
+
# ES: "el/la <subj> está|están <estado-adjetivo>" (estar only, no ser)
|
|
129
|
+
re.compile(
|
|
130
|
+
r"\b(?:el|la|los|las)\s+(?P<subject>[a-z0-9_.\-]{2,30})\s+(?:ya\s+)?(?P<neg>no\s+)?est[áa]n?\s+"
|
|
131
|
+
r"(?:complet[ao]s?|list[oa]s?|terminad[oa]s?|generad[oa]s?|cread[oa]s?|instalad[oa]s?|"
|
|
132
|
+
r"configurad[oa]s?|vac[íi]os?|llenos?|íntegro|integro)\b",
|
|
133
|
+
re.IGNORECASE,
|
|
134
|
+
),
|
|
135
|
+
# EN: "the <subj> is (not) <state-adjective>"
|
|
136
|
+
re.compile(
|
|
137
|
+
r"\bthe\s+(?P<subject>[a-z0-9_.\-]{2,30})\s+is\s+(?P<neg>not\s+)?"
|
|
138
|
+
r"(complete|ready|finished|generated|created|installed|configured|empty|intact)\b",
|
|
139
|
+
re.IGNORECASE,
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
_PATTERNS_BY_KIND = [
|
|
144
|
+
("service_status", _SERVICE_STATUS_PATTERNS),
|
|
145
|
+
("port", _PORT_PATTERNS),
|
|
146
|
+
("file_exists", _FILE_PATTERNS),
|
|
147
|
+
("file_content", _FILE_CONTENT_PATTERNS),
|
|
148
|
+
("version", _VERSION_PATTERNS),
|
|
149
|
+
("generic_state", _GENERIC_STATE_PATTERNS),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Marcadores de contexto instructivo: el match cae dentro de una instrucción
|
|
153
|
+
# al usuario ("puedes ver qué está corriendo"), no es aseveración del agente.
|
|
154
|
+
_INSTRUCTION_PREFIX_PATTERN = re.compile(
|
|
155
|
+
r"\b(puedes?|puedo|debes?|debo|ejecuta[rz]?|usar?|us[áa]|prueba[rz]?|prueb[áa]|"
|
|
156
|
+
r"intentar?|intenta[rz]?|revisar?|revis[áa]|comprobar?|comprueb[áa]|verificar?|verific[áa]|"
|
|
157
|
+
r"abrir?|abre|buscar?|busca|para\s+\w+[ár]|deber[íi]as|podr[íi]as)\b",
|
|
158
|
+
re.IGNORECASE,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Patrón de capacidad: afirmaciones sobre lo que un artefacto "puede" o "es capaz"
|
|
162
|
+
# de hacer no son estados del mundo verificables por trace ("el archivo X no tiene
|
|
163
|
+
# la capacidad de resolver dominios"). Se excluyen como claims.
|
|
164
|
+
_CAPABILITY_PATTERN = re.compile(
|
|
165
|
+
r"\b(capacidad\s+de|capacidad\s+para|capaz\s+de|capable\s+of|ability\s+to|capability\s+to|capable)\b",
|
|
166
|
+
re.IGNORECASE,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Token mínimo para considerar un subject "significativo" en el matching
|
|
170
|
+
_MIN_SUBJECT_TOKEN_LEN = 3
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
# Data model
|
|
175
|
+
# --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class StateClaim:
|
|
180
|
+
"""Una aseveración de estado del mundo detectada en el turno del agente."""
|
|
181
|
+
|
|
182
|
+
text: str
|
|
183
|
+
subject: str
|
|
184
|
+
span: tuple[int, int]
|
|
185
|
+
kind: str # service_status | port | file_exists | file_content | version | generic_state
|
|
186
|
+
confidence: float
|
|
187
|
+
negated: bool = False
|
|
188
|
+
port: str | None = None
|
|
189
|
+
file: str | None = None
|
|
190
|
+
version: str | None = None
|
|
191
|
+
|
|
192
|
+
def keywords(self) -> list[str]:
|
|
193
|
+
"""Tokens deterministas usados para matchear contra el trace."""
|
|
194
|
+
kws = [
|
|
195
|
+
t
|
|
196
|
+
for t in re.split(r"[\s_\-.:()/]+", self.subject)
|
|
197
|
+
if len(t) >= _MIN_SUBJECT_TOKEN_LEN and not t.isdigit()
|
|
198
|
+
]
|
|
199
|
+
if self.port:
|
|
200
|
+
kws.append(self.port)
|
|
201
|
+
if self.file:
|
|
202
|
+
kws.extend(p for p in re.split(r"[\\/]+", self.file) if p)
|
|
203
|
+
if self.version:
|
|
204
|
+
kws.append(self.version)
|
|
205
|
+
return [k.lower() for k in kws]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class AletheicVerdict:
|
|
210
|
+
"""Veredicto de la capa alética."""
|
|
211
|
+
|
|
212
|
+
allowed: bool
|
|
213
|
+
unverified: list[StateClaim] = field(default_factory=list)
|
|
214
|
+
matched: list[tuple[StateClaim, dict]] = field(default_factory=list)
|
|
215
|
+
negated_skipped: list[StateClaim] = field(default_factory=list)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --------------------------------------------------------------------------
|
|
219
|
+
# Detection
|
|
220
|
+
# --------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def detect_state_claims(text: str) -> list[StateClaim]:
|
|
224
|
+
"""Detecta aseveraciones de estado del mundo (teatro alético) en el texto.
|
|
225
|
+
|
|
226
|
+
Devuelve claims afirmados y negados (``negated=True`` para negados).
|
|
227
|
+
Excluye: preguntas, matches en contexto instructivo y matches sobre
|
|
228
|
+
capacidades (heurística determinista sobre la oración completa que
|
|
229
|
+
contiene el match). La negación se detecta intra-match (grupo ``neg``),
|
|
230
|
+
nunca por ventana de prefijo (ver docstring del módulo, decisión 1: el
|
|
231
|
+
"No," discursivo de una oración previa contaminaría el prefijo de matches
|
|
232
|
+
posteriores).
|
|
233
|
+
"""
|
|
234
|
+
claims: list[StateClaim] = []
|
|
235
|
+
seen_spans: set[tuple[int, int]] = set()
|
|
236
|
+
|
|
237
|
+
def _sentence_context(start: int, end: int) -> str:
|
|
238
|
+
"""Devuelve la oración que contiene el span [start, end).
|
|
239
|
+
|
|
240
|
+
Delimitadores: . ? ! ; y nueva línea. No usamos ':' porque aparece
|
|
241
|
+
dentro de instrucciones ("Puedes hacer X: comando Y").
|
|
242
|
+
"""
|
|
243
|
+
left = start
|
|
244
|
+
while left > 0 and text[left - 1] not in ".?!;\n":
|
|
245
|
+
left -= 1
|
|
246
|
+
right = end
|
|
247
|
+
n = len(text)
|
|
248
|
+
while right < n and text[right] not in ".?!;\n":
|
|
249
|
+
right += 1
|
|
250
|
+
return text[left:right]
|
|
251
|
+
|
|
252
|
+
for kind, patterns in _PATTERNS_BY_KIND:
|
|
253
|
+
for pattern in patterns:
|
|
254
|
+
for m in pattern.finditer(text):
|
|
255
|
+
span = (m.start(), m.end())
|
|
256
|
+
if span in seen_spans:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Pregunta: la oración que contiene el match termina en "?"
|
|
260
|
+
sentence_end = text.find("?", m.start())
|
|
261
|
+
next_period = text.find(".", m.end())
|
|
262
|
+
if sentence_end != -1 and (next_period == -1 or sentence_end < next_period):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
sentence = _sentence_context(m.start(), m.end())
|
|
266
|
+
|
|
267
|
+
# Contexto instructivo: marcador en la oración completa.
|
|
268
|
+
# FP clase A VTB: instrucciones largas como
|
|
269
|
+
# "Puedes inspeccionar la configuración con: cat /etc/hosts y
|
|
270
|
+
# luego ver qué contenedores están corriendo" escapan de la
|
|
271
|
+
# ventana de 40 chars. Se evalúa la oración completa.
|
|
272
|
+
if _INSTRUCTION_PREFIX_PATTERN.search(sentence):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# Capacidad: no es un estado del mundo verificable por trace.
|
|
276
|
+
# FP clase B VTB: "el archivo /etc/hosts no tiene la capacidad
|
|
277
|
+
# de resolver dominios locales".
|
|
278
|
+
if _CAPABILITY_PATTERN.search(sentence):
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
gd = m.groupdict()
|
|
282
|
+
subject = gd.get("subject") or gd.get("file") or ""
|
|
283
|
+
negated = bool(gd.get("neg"))
|
|
284
|
+
confidence = 0.9 if kind in ("port", "service_status", "file_exists", "file_content") else 0.7
|
|
285
|
+
|
|
286
|
+
claims.append(
|
|
287
|
+
StateClaim(
|
|
288
|
+
text=m.group(0),
|
|
289
|
+
subject=subject.strip(),
|
|
290
|
+
span=span,
|
|
291
|
+
kind=kind,
|
|
292
|
+
confidence=confidence,
|
|
293
|
+
negated=negated,
|
|
294
|
+
port=gd.get("port"),
|
|
295
|
+
file=gd.get("file"),
|
|
296
|
+
version=gd.get("version"),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
seen_spans.add(span)
|
|
300
|
+
|
|
301
|
+
return claims
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------
|
|
305
|
+
# Verification against traces
|
|
306
|
+
# --------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _claim_matches_trace(claim: StateClaim, trace: dict) -> bool:
|
|
310
|
+
"""Matching determinista claim↔trace.
|
|
311
|
+
|
|
312
|
+
Regla: exit_code == 0 Y todos los keywords del claim aparecen en
|
|
313
|
+
(command + stdout_excerpt). Keywords = subject tokens + port/file/version.
|
|
314
|
+
"""
|
|
315
|
+
if trace.get("exit_code") != 0:
|
|
316
|
+
return False
|
|
317
|
+
haystack = f"{trace.get('command', '')} {trace.get('stdout_excerpt', '')}".lower()
|
|
318
|
+
kws = claim.keywords()
|
|
319
|
+
if not kws:
|
|
320
|
+
return False
|
|
321
|
+
return all(k in haystack for k in kws)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def verify_aletheic(
|
|
325
|
+
claims: list[StateClaim],
|
|
326
|
+
trace: list[dict] | None,
|
|
327
|
+
lenient_negation: bool = False,
|
|
328
|
+
) -> AletheicVerdict:
|
|
329
|
+
"""Verifica claims aléticos contra traces de ejecución de herramientas.
|
|
330
|
+
|
|
331
|
+
Políticas:
|
|
332
|
+
- trace presente y matchea -> ALLOW (claim va a ``matched``)
|
|
333
|
+
- trace presente, no matchea -> REJECT (claim va a ``unverified``)
|
|
334
|
+
- trace ausente -> REJECT (todo claim va a ``unverified``)
|
|
335
|
+
- claim negado -> exige trace como cualquier claim
|
|
336
|
+
(estricto por defecto: una negación de estado del mundo sigue siendo
|
|
337
|
+
una aseveración que requiere evidencia, ej. "el puerto 8000 no está
|
|
338
|
+
reservado" sin verificación = teatro VTB SYS-01). Con
|
|
339
|
+
``lenient_negation=True`` los negados se saltan (``negated_skipped``).
|
|
340
|
+
|
|
341
|
+
``allowed`` es True solo si ``unverified`` queda vacío.
|
|
342
|
+
"""
|
|
343
|
+
matched: list[tuple[StateClaim, dict]] = []
|
|
344
|
+
unverified: list[StateClaim] = []
|
|
345
|
+
negated_skipped: list[StateClaim] = []
|
|
346
|
+
|
|
347
|
+
traces = trace or []
|
|
348
|
+
for claim in claims:
|
|
349
|
+
if claim.negated and lenient_negation:
|
|
350
|
+
negated_skipped.append(claim)
|
|
351
|
+
continue
|
|
352
|
+
hit = next((t for t in traces if _claim_matches_trace(claim, t)), None)
|
|
353
|
+
if hit is not None:
|
|
354
|
+
matched.append((claim, hit))
|
|
355
|
+
else:
|
|
356
|
+
unverified.append(claim)
|
|
357
|
+
|
|
358
|
+
return AletheicVerdict(
|
|
359
|
+
allowed=len(unverified) == 0,
|
|
360
|
+
unverified=unverified,
|
|
361
|
+
matched=matched,
|
|
362
|
+
negated_skipped=negated_skipped,
|
|
363
|
+
)
|